IGNITE-14920: Implement Spring Sessions Using Ignite As Backing Store (#63)
diff --git a/modules/spring-session-ext/pom.xml b/modules/spring-session-ext/pom.xml
new file mode 100644
index 0000000..1077de8
--- /dev/null
+++ b/modules/spring-session-ext/pom.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <artifactId>apache-ignite-extensions</artifactId>
+ <groupId>org.apache.ignite</groupId>
+ <version>1.0.0-SNAPSHOT</version>
+ <relativePath>../../pom.xml</relativePath>
+ </parent>
+ <modelVersion>4.0.0</modelVersion>
+
+ <artifactId>spring-session-ext</artifactId>
+
+ <properties>
+ <ignite.version>2.10.0</ignite.version>
+ <spring.version>5.3.8</spring.version>
+ <spring.session.version>2.5.0</spring.session.version>
+ <spring.security.version>5.5.0</spring.security.version>
+ <javax.annotation.version>1.3.2</javax.annotation.version>
+ <assertj.version>3.20.0</assertj.version>
+ <junit.jupiter.version>5.7.2</junit.jupiter.version>
+ <javax.servlet.version>3.0.1</javax.servlet.version>
+ <mockito.version>2.22.0</mockito.version>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.ignite</groupId>
+ <artifactId>ignite-core</artifactId>
+ <version>${ignite.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-core</artifactId>
+ <version>${spring.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-expression</artifactId>
+ <version>${spring.version}</version>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-core -->
+ <dependency>
+ <groupId>org.springframework.security</groupId>
+ <artifactId>spring-security-core</artifactId>
+ <version>${spring.security.version}</version>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-core -->
+ <dependency>
+ <groupId>org.springframework.session</groupId>
+ <artifactId>spring-session-core</artifactId>
+ <version>${spring.session.version}</version>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.springframework/spring-context -->
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context</artifactId>
+ <version>${spring.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>javax.annotation</groupId>
+ <artifactId>javax.annotation-api</artifactId>
+ <version>${javax.annotation.version}</version>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.assertj/assertj-core -->
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <version>${assertj.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <version>${junit.jupiter.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-test</artifactId>
+ <version>${spring.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>javax.servlet</groupId>
+ <artifactId>javax.servlet-api</artifactId>
+ <version>${javax.servlet.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.springframework/spring-web -->
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-web</artifactId>
+ <version>${spring.version}</version>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.apache.ignite/ignite-indexing -->
+ <dependency>
+ <groupId>org.apache.ignite</groupId>
+ <artifactId>ignite-indexing</artifactId>
+ <version>${ignite.version}</version>
+ </dependency>
+
+ <!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/EnableIgniteHttpSession.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/EnableIgniteHttpSession.java
new file mode 100644
index 0000000..787496f
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/EnableIgniteHttpSession.java
@@ -0,0 +1,98 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.apache.ignite.Ignite;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.session.FlushMode;
+import org.springframework.session.MapSession;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.SessionRepository;
+import org.springframework.session.web.http.SessionRepositoryFilter;
+
+/**
+ * Add this annotation to an {@code @Configuration} class to expose the
+ * {@link SessionRepositoryFilter} as a bean named {@code springSessionRepositoryFilter}
+ * and backed by Ignite. In order to leverage the annotation, a single {@link Ignite} must
+ * be provided. For example:
+ *
+ * <pre class="code">
+ * @Configuration
+ * @EnableIgniteHttpSession
+ * public class IgniteHttpSessionConfig {
+ *
+ * @Bean
+ * public Ignite embeddedIgnite() {
+ * return IgniteEx.start();
+ * }
+ *
+ * }
+ * </pre>
+ *
+ * More advanced configurations can extend {@link IgniteHttpSessionConfiguration} instead.
+ *
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Documented
+@Import(IgniteHttpSessionConfiguration.class)
+@Configuration(proxyBeanMethods = false)
+public @interface EnableIgniteHttpSession {
+ /**
+ * The session timeout in seconds. By default, it is set to 1800 seconds (30 minutes).
+ * This should be a non-negative integer.
+ * @return the seconds a session can be inactive before expiring
+ */
+ int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
+
+ /**
+ * This is the name of the Map that will be used in Ignite to store the session data.
+ * Default is "spring:session:sessions".
+ * @return the name of the Map to store the sessions in Ignite
+ */
+ String sessionMapName() default "spring:session:sessions";
+
+ /**
+ * Flush mode for the Ignite sessions. The default is {@code ON_SAVE} which only
+ * updates the backing Ignite when {@link SessionRepository#save(Session)} is invoked.
+ * In a web environment this happens just before the HTTP response is committed.
+ * <p>
+ * Setting the value to {@code IMMEDIATE} will ensure that the any updates to the
+ * Session are immediately written to the Ignite instance.
+ * @return the {@link FlushMode} to use
+ * @since 2.2.0
+ */
+ FlushMode flushMode() default FlushMode.ON_SAVE;
+
+ /**
+ * Save mode for the session. The default is {@link SaveMode#ON_SET_ATTRIBUTE}, which
+ * only saves changes made to session.
+ * @return the save mode
+ * @since 2.2.0
+ */
+ SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE;
+
+}
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfiguration.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfiguration.java
new file mode 100644
index 0000000..36aa7e3
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfiguration.java
@@ -0,0 +1,164 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.ignite.Ignite;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.ImportAware;
+import org.springframework.core.annotation.AnnotationAttributes;
+import org.springframework.core.type.AnnotationMetadata;
+import org.springframework.session.FlushMode;
+import org.springframework.session.IndexResolver;
+import org.springframework.session.MapSession;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.SessionRepository;
+import org.springframework.session.config.SessionRepositoryCustomizer;
+import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration;
+import org.springframework.session.web.http.SessionRepositoryFilter;
+import org.springframework.util.StringUtils;
+
+/**
+ * Exposes the {@link SessionRepositoryFilter} as a bean named
+ * {@code springSessionRepositoryFilter}. In order to use this a single {@link Ignite}
+ * must be exposed as a Bean.
+ */
+@Configuration(proxyBeanMethods = false)
+public class IgniteHttpSessionConfiguration extends SpringHttpSessionConfiguration implements ImportAware {
+ /** */
+ private Integer maxInactiveIntervalInSeconds = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
+
+ /** */
+ private String sessionMapName = IgniteIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME;
+
+ /** */
+ private FlushMode flushMode = FlushMode.ON_SAVE;
+
+ /** */
+ private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE;
+
+ /** */
+ private Ignite ignite;
+
+ /** */
+ private ApplicationEventPublisher applicationEventPublisher;
+
+ /** */
+ private IndexResolver<Session> indexResolver;
+
+ /** */
+ private List<SessionRepositoryCustomizer<IgniteIndexedSessionRepository>> sessionRepositoryCustomizers;
+
+ /** */
+ @Bean
+ public SessionRepository<?> sessionRepository() {
+ return createIgniteIndexedSessionRepository();
+ }
+
+ /** */
+ public void setMaxInactiveIntervalInSeconds(int maxInactiveIntervalInSeconds) {
+ this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds;
+ }
+
+ /** */
+ public void setSessionMapName(String sessionMapName) {
+ this.sessionMapName = sessionMapName;
+ }
+
+ /**
+ *
+ */
+ public void setFlushMode(FlushMode flushMode) {
+ this.flushMode = flushMode;
+ }
+
+ /** */
+ public void setSaveMode(SaveMode saveMode) {
+ this.saveMode = saveMode;
+ }
+
+ /** */
+ @Autowired
+ public void setIgnite(@SpringSessionIgnite ObjectProvider<Ignite> springSessionIgnite,
+ ObjectProvider<Ignite> ignite) {
+ Ignite igniteToUse = springSessionIgnite.getIfAvailable();
+ if (igniteToUse == null)
+ igniteToUse = ignite.getObject();
+
+ this.ignite = igniteToUse;
+ }
+
+ /** */
+ @Autowired
+ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
+ this.applicationEventPublisher = applicationEventPublisher;
+ }
+
+ /** */
+ @Autowired(required = false)
+ public void setIndexResolver(IndexResolver<Session> indexResolver) {
+ this.indexResolver = indexResolver;
+ }
+
+ /** */
+ @Autowired(required = false)
+ public void setSessionRepositoryCustomizer(
+ ObjectProvider<SessionRepositoryCustomizer<IgniteIndexedSessionRepository>> sessionRepositoryCustomizers) {
+ this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList());
+ }
+
+ /** */
+ @Override @SuppressWarnings("deprecation") public void setImportMetadata(AnnotationMetadata importMetadata) {
+ Map<String, Object> attributeMap = importMetadata
+ .getAnnotationAttributes(EnableIgniteHttpSession.class.getName());
+ AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap);
+ this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds");
+ String sessionMapNameValue = attributes.getString("sessionMapName");
+ if (StringUtils.hasText(sessionMapNameValue))
+ this.sessionMapName = sessionMapNameValue;
+
+ this.flushMode = attributes.getEnum("flushMode");
+ this.saveMode = attributes.getEnum("saveMode");
+ }
+
+ /** */
+ private IgniteIndexedSessionRepository createIgniteIndexedSessionRepository() {
+ IgniteIndexedSessionRepository sessionRepository = new IgniteIndexedSessionRepository(this.ignite);
+ sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
+ if (this.indexResolver != null)
+ sessionRepository.setIndexResolver(this.indexResolver);
+
+ if (StringUtils.hasText(this.sessionMapName))
+ sessionRepository.setSessionMapName(this.sessionMapName);
+
+ sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
+ sessionRepository.setFlushMode(this.flushMode);
+ sessionRepository.setSaveMode(this.saveMode);
+ this.sessionRepositoryCustomizers
+ .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository));
+ return sessionRepository;
+ }
+}
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepository.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepository.java
new file mode 100644
index 0000000..8fe5ff0
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepository.java
@@ -0,0 +1,555 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import javax.cache.configuration.CacheEntryListenerConfiguration;
+import javax.cache.configuration.Factory;
+import javax.cache.event.CacheEntryCreatedListener;
+import javax.cache.event.CacheEntryEvent;
+import javax.cache.event.CacheEntryEventFilter;
+import javax.cache.event.CacheEntryExpiredListener;
+import javax.cache.event.CacheEntryListener;
+import javax.cache.event.CacheEntryListenerException;
+import javax.cache.event.CacheEntryRemovedListener;
+import javax.cache.expiry.TouchedExpiryPolicy;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.cache.query.FieldsQueryCursor;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.GridDirectTransient;
+
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.session.DelegatingIndexResolver;
+import org.springframework.session.FindByIndexNameSessionRepository;
+import org.springframework.session.FlushMode;
+import org.springframework.session.IndexResolver;
+import org.springframework.session.MapSession;
+import org.springframework.session.PrincipalNameIndexResolver;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.events.AbstractSessionEvent;
+import org.springframework.session.events.SessionCreatedEvent;
+import org.springframework.session.events.SessionDeletedEvent;
+import org.springframework.session.events.SessionExpiredEvent;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link org.springframework.session.SessionRepository} implementation that stores
+ * sessions in Apache Ignite distributed {@link IgniteCache}.
+ *
+ * <p>
+ * An example of how to create a new instance can be seen below:
+ *
+ * <pre class="code">
+ * IgniteConfiguration config = new IgniteConfiguration();
+ *
+ * // ... configure Ignite ...
+ *
+ * Ignite ignite = IgnitionEx.start(config);
+ *
+ * IgniteIndexedSessionRepository sessionRepository =
+ * new IgniteIndexedSessionRepository(ignite);
+ * </pre>
+ *
+ * In order to support finding sessions by principal name using
+ * {@link #findByIndexNameAndIndexValue(String, String)} method, custom configuration of
+ * {@link IgniteCache} supplied to this implementation is required.
+ *
+ * This implementation listens for events on the Ignite-backed SessionRepository and
+ * translates those events into the corresponding Spring Session events. Publish the
+ * Spring Session events with the given {@link ApplicationEventPublisher}.
+ *
+ * <ul>
+ * <li>entryAdded - {@link SessionCreatedEvent}</li>
+ * <li>entryEvicted - {@link SessionExpiredEvent}</li>
+ * <li>entryRemoved - {@link SessionDeletedEvent}</li>
+ * </ul>
+ *
+ */
+public class IgniteIndexedSessionRepository
+ implements FindByIndexNameSessionRepository<IgniteIndexedSessionRepository.IgniteSession>,
+ CacheEntryCreatedListener<String, IgniteIndexedSessionRepository.IgniteSession>,
+ CacheEntryRemovedListener<String, IgniteIndexedSessionRepository.IgniteSession>,
+ CacheEntryExpiredListener<String, IgniteIndexedSessionRepository.IgniteSession> {
+ /**
+ * The default name of map used by Spring Session to store sessions.
+ */
+ public static final String DEFAULT_SESSION_MAP_NAME = "spring:session:sessions";
+
+ /** */
+ private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
+
+ /** */
+ private static final Log logger = LogFactory.getLog(IgniteIndexedSessionRepository.class);
+
+ /** */
+ private final Ignite ignite;
+
+ /** */
+ private ApplicationEventPublisher eventPublisher = (event) -> {
+ };
+
+ /**
+ * If non-null, this value is used to override
+ * {@link MapSession#setMaxInactiveInterval(Duration)}.
+ */
+ private Integer defaultMaxInactiveInterval;
+
+ /** */
+ private IndexResolver<Session> indexResolver = new DelegatingIndexResolver<>(new PrincipalNameIndexResolver<>());
+
+ /** */
+ private String sessionMapName = DEFAULT_SESSION_MAP_NAME;
+
+ /** */
+ private FlushMode flushMode = FlushMode.ON_SAVE;
+
+ /** */
+ private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE;
+
+ /** */
+ private IgniteCache<String, IgniteSession> sessions;
+
+ /** */
+ private CacheEntryListenerConfiguration<String, IgniteSession> listenerConfiguration;
+
+ /**
+ * Create a new {@link IgniteIndexedSessionRepository} instance.
+ * @param ignite the {@link Ignite} instance to use for managing sessions
+ */
+ public IgniteIndexedSessionRepository(Ignite ignite) {
+ Assert.notNull(ignite, "Ignite must not be null");
+ this.ignite = ignite;
+ }
+
+ /** */
+ @PostConstruct
+ public void init() {
+ final CacheConfiguration<String, IgniteSession> configuration = new CacheConfiguration<String, IgniteSession>(
+ this.sessionMapName).setIndexedTypes(String.class, IgniteSession.class);
+
+ if (this.defaultMaxInactiveInterval != null)
+ configuration.setExpiryPolicyFactory(TouchedExpiryPolicy
+ .factoryOf(new javax.cache.expiry.Duration(TimeUnit.SECONDS, this.defaultMaxInactiveInterval)));
+
+ this.sessions = this.ignite.getOrCreateCache(configuration);
+
+ this.listenerConfiguration = new CacheEntryListenerConfiguration<String, IgniteSession>() {
+ @Override public Factory<CacheEntryListener<? super String, ? super IgniteSession>> getCacheEntryListenerFactory() {
+ return (Factory<CacheEntryListener<? super String, ? super IgniteSession>>) () -> IgniteIndexedSessionRepository.this;
+ }
+
+ @Override public boolean isOldValueRequired() {
+ return true;
+ }
+
+ @Override public Factory<CacheEntryEventFilter<? super String, ? super IgniteSession>> getCacheEntryEventFilterFactory() {
+ return null;
+ }
+
+ @Override public boolean isSynchronous() {
+ return false;
+ }
+ };
+ this.sessions.registerCacheEntryListener(this.listenerConfiguration);
+ }
+
+ /** */
+ @PreDestroy
+ public void close() {
+ this.sessions.deregisterCacheEntryListener(this.listenerConfiguration);
+ }
+
+ /**
+ * Sets the {@link ApplicationEventPublisher} that is used to publish
+ * {@link AbstractSessionEvent session events}. The default is to not publish session
+ * events.
+ * @param applicationEventPublisher the {@link ApplicationEventPublisher} that is used
+ * to publish session events. Cannot be null.
+ */
+ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
+ Assert.notNull(applicationEventPublisher, "ApplicationEventPublisher cannot be null");
+ this.eventPublisher = applicationEventPublisher;
+ }
+
+ /**
+ * Set the maximum inactive interval in seconds between requests before newly created
+ * sessions will be invalidated. A negative time indicates that the session will never
+ * timeout. The default is 1800 (30 minutes).
+ * @param defaultMaxInactiveInterval the maximum inactive interval in seconds
+ */
+ public void setDefaultMaxInactiveInterval(Integer defaultMaxInactiveInterval) {
+ this.defaultMaxInactiveInterval = defaultMaxInactiveInterval;
+ }
+
+ /**
+ * Set the {@link IndexResolver} to use.
+ * @param indexResolver the index resolver
+ */
+ public void setIndexResolver(IndexResolver<Session> indexResolver) {
+ Assert.notNull(indexResolver, "indexResolver cannot be null");
+ this.indexResolver = indexResolver;
+ }
+
+ /**
+ * Set the name of map used to store sessions.
+ * @param sessionMapName the session map name
+ */
+ public void setSessionMapName(String sessionMapName) {
+ Assert.hasText(sessionMapName, "Map name must not be empty");
+ this.sessionMapName = sessionMapName;
+ }
+
+ /**
+ * Sets the flush mode. Default flush mode is {@link FlushMode#ON_SAVE}.
+ * @param flushMode the new flush mode
+ */
+ public void setFlushMode(FlushMode flushMode) {
+ Assert.notNull(flushMode, "flushMode cannot be null");
+ this.flushMode = flushMode;
+ }
+
+ /**
+ * Set the save mode.
+ * @param saveMode the save mode
+ */
+ public void setSaveMode(SaveMode saveMode) {
+ Assert.notNull(saveMode, "saveMode must not be null");
+ this.saveMode = saveMode;
+ }
+
+ /** */
+ @Override public IgniteSession createSession() {
+ MapSession cached = new MapSession();
+ if (this.defaultMaxInactiveInterval != null)
+ cached.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval));
+
+ IgniteSession session = new IgniteSession(cached, true);
+ session.flushImmediateIfNecessary();
+ return session;
+ }
+
+ /** */
+ @Override public void save(IgniteSession session) {
+ if (session.isNew)
+ ttlSessions(session.getMaxInactiveInterval()).put(session.getId(), session);
+
+ else if (session.sessionIdChanged) {
+ this.sessions.remove(session.originalId);
+ session.originalId = session.getId();
+ ttlSessions(session.getMaxInactiveInterval()).put(session.getId(), session);
+ }
+ else if (session.hasChanges()) {
+ if (session.maxInactiveIntervalChanged) {
+ ttlSessions(session.getMaxInactiveInterval()).replace(session.getId(), session);
+ }
+ else {
+ this.sessions.replace(session.getId(), session);
+ }
+ }
+ session.clearChangeFlags();
+ }
+
+ /** */
+ @Override public IgniteSession findById(String id) {
+ IgniteSession saved = this.sessions.get(id);
+ if (saved == null)
+ return null;
+
+ if (saved.isExpired()) {
+ deleteById(saved.getId());
+ return null;
+ }
+ saved.isNew = false;
+ return saved;
+ }
+
+ /** */
+ @Override public void deleteById(String id) {
+ this.sessions.remove(id);
+ }
+
+ /** */
+ @Override public Map<String, IgniteSession> findByIndexNameAndIndexValue(String indexName, String indexValue) {
+ if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName))
+ return Collections.emptyMap();
+
+ final FieldsQueryCursor<List<?>> cursor = this.sessions
+ .query(new SqlFieldsQuery("SELECT * FROM IgniteSession WHERE principal='" + indexValue + "'"));
+
+ if (cursor == null)
+ return Collections.emptyMap();
+
+ final List<List<?>> sessions = cursor.getAll();
+
+ Map<String, IgniteSession> sessionMap = new HashMap<>(sessions.size());
+ sessions.forEach((List<?> res) -> {
+ final MapSession session = (MapSession) res.get(0);
+ final IgniteSession value = new IgniteSession(session, false);
+ value.principal = (String) res.get(1);
+ sessionMap.put(session.getId(), value);
+ });
+
+ return sessionMap;
+ }
+
+ /** */
+ @Override public void onCreated(Iterable<CacheEntryEvent<? extends String, ? extends IgniteSession>> events)
+ throws CacheEntryListenerException {
+ events.forEach((event) -> {
+ IgniteSession session = event.getValue();
+ if (session.getId().equals(session.getDelegate().getOriginalId())) {
+ if (logger.isDebugEnabled())
+ logger.debug("Session created with id: " + session.getId());
+
+ this.eventPublisher.publishEvent(new SessionCreatedEvent(this, session));
+ }
+ });
+ }
+
+ /** */
+ @Override
+ public void onExpired(Iterable<CacheEntryEvent<? extends String, ? extends IgniteSession>> events)
+ throws CacheEntryListenerException {
+ events.forEach((event) -> {
+ if (logger.isDebugEnabled())
+ logger.debug("Session expired with id: " + event.getOldValue().getId());
+
+ this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue()));
+ });
+ }
+
+ /** */
+ @Override public void onRemoved(Iterable<CacheEntryEvent<? extends String, ? extends IgniteSession>> events)
+ throws CacheEntryListenerException {
+ events.forEach((event) -> {
+ IgniteSession session = event.getOldValue();
+ if (session != null) {
+ if (logger.isDebugEnabled())
+ logger.debug("Session deleted with id: " + session.getId());
+
+ this.eventPublisher.publishEvent(new SessionDeletedEvent(this, session));
+ }
+ });
+ }
+
+ /**
+ * Get cache view with custom duration expiry policy.
+ * @param duration expiry duration for IgniteSession.
+ * @return cache with custom duration expiry policy.
+ */
+ private IgniteCache<String, IgniteSession> ttlSessions(Duration duration) {
+ return this.sessions.withExpiryPolicy(createPolicy(duration));
+ }
+
+ /**
+ * Create expiry policy from {@link Duration}.
+ * @param duration expiry duration.
+ * @return expiry policy.
+ */
+ private static TouchedExpiryPolicy createPolicy(Duration duration) {
+ return new TouchedExpiryPolicy(new javax.cache.expiry.Duration(TimeUnit.SECONDS, duration.getSeconds()));
+ }
+
+ /**
+ * A custom implementation of {@link Session} that uses a {@link MapSession} as the
+ * basis for its mapping. It keeps track if changes have been made since last save.
+ */
+ final class IgniteSession implements Session {
+
+ /** */
+ @QuerySqlField
+ private final MapSession delegate;
+
+ /** */
+ @GridDirectTransient
+ private boolean isNew;
+
+ /** */
+ @GridDirectTransient
+ private boolean sessionIdChanged;
+
+ /** */
+ @GridDirectTransient
+ private boolean lastAccessedTimeChanged;
+
+ /** */
+ @GridDirectTransient
+ private boolean maxInactiveIntervalChanged;
+
+ /** */
+ @GridDirectTransient
+ private String originalId;
+
+ /** */
+ @GridDirectTransient
+ private Map<String, Object> delta = new HashMap<>();
+
+ /** */
+ @QuerySqlField(index = true)
+ private String principal;
+
+ IgniteSession(MapSession cached, boolean isNew) {
+ this.delegate = cached;
+ this.isNew = isNew;
+ this.originalId = cached.getId();
+ if (this.isNew || (IgniteIndexedSessionRepository.this.saveMode == SaveMode.ALWAYS))
+ getAttributeNames()
+ .forEach((attributeName) -> this.delta.put(attributeName, cached.getAttribute(attributeName)));
+
+ }
+
+ /** */
+ @Override public void setLastAccessedTime(Instant lastAccessedTime) {
+ this.delegate.setLastAccessedTime(lastAccessedTime);
+ this.lastAccessedTimeChanged = true;
+ flushImmediateIfNecessary();
+ }
+
+ /** */
+ @Override public boolean isExpired() {
+ return this.delegate.isExpired();
+ }
+
+ /** */
+ @Override public Instant getCreationTime() {
+ return this.delegate.getCreationTime();
+ }
+
+ /** */
+ @Override public String getId() {
+ return this.delegate.getId();
+ }
+
+ /** */
+ @Override public String changeSessionId() {
+ String newSessionId = this.delegate.changeSessionId();
+ this.sessionIdChanged = true;
+ return newSessionId;
+ }
+
+ /** */
+ @Override public Instant getLastAccessedTime() {
+ return this.delegate.getLastAccessedTime();
+ }
+
+ /** */
+ @Override public void setMaxInactiveInterval(Duration interval) {
+ this.delegate.setMaxInactiveInterval(interval);
+ this.maxInactiveIntervalChanged = true;
+ flushImmediateIfNecessary();
+ }
+
+ /** */
+ @Override public Duration getMaxInactiveInterval() {
+ return this.delegate.getMaxInactiveInterval();
+ }
+
+ /** */
+ @Override public <T> T getAttribute(String attributeName) {
+ T attributeValue = this.delegate.getAttribute(attributeName);
+ if (attributeValue != null
+ && IgniteIndexedSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE))
+ this.delta.put(attributeName, attributeValue);
+
+ return attributeValue;
+ }
+
+ /** */
+ @Override public Set<String> getAttributeNames() {
+ return this.delegate.getAttributeNames();
+ }
+
+ /** */
+ @Override public void setAttribute(String attributeName, Object attributeValue) {
+ this.delegate.setAttribute(attributeName, attributeValue);
+ this.delta.put(attributeName, attributeValue);
+ if (SPRING_SECURITY_CONTEXT.equals(attributeName)) {
+ Map<String, String> indexes = IgniteIndexedSessionRepository.this.indexResolver.resolveIndexesFor(this);
+ String principal = (attributeValue != null) ? indexes.get(PRINCIPAL_NAME_INDEX_NAME) : null;
+ this.delegate.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal);
+ this.principal = principal;
+ }
+ flushImmediateIfNecessary();
+ }
+
+ /** */
+ @Override public void removeAttribute(String attributeName) {
+ setAttribute(attributeName, null);
+ }
+
+ /** */
+ MapSession getDelegate() {
+ return this.delegate;
+ }
+
+ /** */
+ boolean hasChanges() {
+ return (this.lastAccessedTimeChanged || this.maxInactiveIntervalChanged || !this.delta.isEmpty());
+ }
+
+ /** */
+ void clearChangeFlags() {
+ this.isNew = false;
+ this.lastAccessedTimeChanged = false;
+ this.sessionIdChanged = false;
+ this.maxInactiveIntervalChanged = false;
+ this.delta.clear();
+ }
+
+ /** */
+ private void flushImmediateIfNecessary() {
+ if (IgniteIndexedSessionRepository.this.flushMode == FlushMode.IMMEDIATE)
+ IgniteIndexedSessionRepository.this.save(this);
+ }
+
+ /** */
+ @Override public boolean equals(Object o) {
+ if (this == o)
+ return true;
+
+ if (o == null || getClass() != o.getClass())
+ return false;
+
+ IgniteSession session = (IgniteSession) o;
+ return this.delegate.equals(session.delegate);
+ }
+
+ /** */
+ @Override public int hashCode() {
+ return Objects.hash(this.delegate);
+ }
+ }
+}
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/SpringSessionIgnite.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/SpringSessionIgnite.java
new file mode 100644
index 0000000..82b51a4
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/SpringSessionIgnite.java
@@ -0,0 +1,39 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.apache.ignite.Ignite;
+import org.springframework.beans.factory.annotation.Qualifier;
+
+/**
+ * Qualifier annotation for a {@link Ignite} to be injected in
+ * {@link IgniteIndexedSessionRepository}.
+ *
+ */
+@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Qualifier
+public @interface SpringSessionIgnite {
+}
diff --git a/modules/spring-session-ext/src/main/java/package-info.java b/modules/spring-session-ext/src/main/java/package-info.java
new file mode 100644
index 0000000..8c21795
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * <!-- Package description. -->
+ * Contains classes that allow using Ignite as backing store for storing Spring sessions.
+ */
+package org.apache.ignite.spring.sessions;
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/AbstractIgniteIndexedSessionRepositoryITest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/AbstractIgniteIndexedSessionRepositoryITest.java
new file mode 100644
index 0000000..7a6036a
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/AbstractIgniteIndexedSessionRepositoryITest.java
@@ -0,0 +1,242 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import java.time.Duration;
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.session.FindByIndexNameSessionRepository;
+
+import org.apache.ignite.spring.sessions.IgniteIndexedSessionRepository.IgniteSession;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Base class for {@link IgniteIndexedSessionRepository} integration tests.
+ */
+abstract class AbstractIgniteIndexedSessionRepositoryITest {
+ /** */
+ private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
+
+ /** */
+ @Autowired
+ private Ignite ignite;
+
+ /** */
+ @Autowired
+ private IgniteIndexedSessionRepository repository;
+
+ /** */
+ @Test
+ void createAndDestroySession() {
+ IgniteIndexedSessionRepository.IgniteSession sessionToSave = this.repository.createSession();
+ String sessionId = sessionToSave.getId();
+
+ IgniteCache<String, IgniteIndexedSessionRepository.IgniteSession> cache = this.ignite
+ .getOrCreateCache(IgniteIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME);
+
+ assertThat(cache.size()).isEqualTo(0);
+
+ this.repository.save(sessionToSave);
+
+ assertThat(cache.size()).isEqualTo(1);
+ assertThat(cache.get(sessionId)).isEqualTo(sessionToSave);
+
+ this.repository.deleteById(sessionId);
+
+ assertThat(cache.size()).isEqualTo(0);
+ }
+
+ /** */
+ @Test
+ void changeSessionIdWhenOnlyChangeId() {
+ String attrName = "changeSessionId";
+ String attrValue = "changeSessionId-value";
+ IgniteSession toSave = this.repository.createSession();
+ toSave.setAttribute(attrName, attrValue);
+
+ this.repository.save(toSave);
+
+ IgniteSession findById = this.repository.findById(toSave.getId());
+
+ assertThat(findById.<String>getAttribute(attrName)).isEqualTo(attrValue);
+
+ String originalFindById = findById.getId();
+ String changeSessionId = findById.changeSessionId();
+
+ this.repository.save(findById);
+
+ assertThat(this.repository.findById(originalFindById)).isNull();
+
+ IgniteSession findByChangeSessionId = this.repository.findById(changeSessionId);
+
+ assertThat(findByChangeSessionId.<String>getAttribute(attrName)).isEqualTo(attrValue);
+
+ this.repository.deleteById(changeSessionId);
+ }
+
+ /** */
+ @Test
+ void changeSessionIdWhenChangeTwice() {
+ IgniteSession toSave = this.repository.createSession();
+
+ this.repository.save(toSave);
+
+ String originalId = toSave.getId();
+ String changeId1 = toSave.changeSessionId();
+ String changeId2 = toSave.changeSessionId();
+
+ this.repository.save(toSave);
+
+ assertThat(this.repository.findById(originalId)).isNull();
+ assertThat(this.repository.findById(changeId1)).isNull();
+ assertThat(this.repository.findById(changeId2)).isNotNull();
+
+ this.repository.deleteById(changeId2);
+ }
+
+ /** */
+ @Test
+ void changeSessionIdWhenSetAttributeOnChangedSession() {
+ String attrName = "changeSessionId";
+ String attrValue = "changeSessionId-value";
+
+ IgniteSession toSave = this.repository.createSession();
+
+ this.repository.save(toSave);
+
+ IgniteSession findById = this.repository.findById(toSave.getId());
+
+ findById.setAttribute(attrName, attrValue);
+
+ String originalFindById = findById.getId();
+ String changeSessionId = findById.changeSessionId();
+
+ this.repository.save(findById);
+
+ assertThat(this.repository.findById(originalFindById)).isNull();
+
+ IgniteSession findByChangeSessionId = this.repository.findById(changeSessionId);
+
+ assertThat(findByChangeSessionId.<String>getAttribute(attrName)).isEqualTo(attrValue);
+
+ this.repository.deleteById(changeSessionId);
+ }
+
+ /** */
+ @Test
+ void changeSessionIdWhenHasNotSaved() {
+ IgniteSession toSave = this.repository.createSession();
+ String originalId = toSave.getId();
+ toSave.changeSessionId();
+
+ this.repository.save(toSave);
+
+ assertThat(this.repository.findById(toSave.getId())).isNotNull();
+ assertThat(this.repository.findById(originalId)).isNull();
+
+ this.repository.deleteById(toSave.getId());
+ }
+
+ @Test
+ void attemptToUpdateSessionAfterDelete() {
+ IgniteSession session = this.repository.createSession();
+ String sessionId = session.getId();
+ this.repository.save(session);
+ session = this.repository.findById(sessionId);
+ session.setAttribute("attributeName", "attributeValue");
+ this.repository.deleteById(sessionId);
+ this.repository.save(session);
+
+ assertThat(this.repository.findById(sessionId)).isNull();
+ }
+
+ /** */
+ @Test
+ void expireSession() {
+ IgniteSession session = this.repository.createSession();
+ String sessionId = session.getId();
+
+ session.setMaxInactiveInterval(Duration.ofNanos(0));
+
+ this.repository.save(session);
+ assertThat(this.repository.findById(sessionId)).isNull();
+ }
+
+ /** */
+ @Test
+ void createAndUpdateSession() {
+ IgniteSession session = this.repository.createSession();
+ String sessionId = session.getId();
+
+ this.repository.save(session);
+
+ session = this.repository.findById(sessionId);
+ session.setAttribute("attributeName", "attributeValue");
+
+ this.repository.save(session);
+
+ assertThat(this.repository.findById(sessionId)).isNotNull();
+ }
+
+ /** */
+ @Test
+ void createSessionWithSecurityContextAndFindById() {
+ IgniteSession session = this.repository.createSession();
+ String sessionId = session.getId();
+
+ Authentication authentication = new UsernamePasswordAuthenticationToken("saves-" + System.currentTimeMillis(),
+ "password", AuthorityUtils.createAuthorityList("ROLE_USER"));
+ SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+ securityContext.setAuthentication(authentication);
+ session.setAttribute(SPRING_SECURITY_CONTEXT, securityContext);
+
+ this.repository.save(session);
+
+ assertThat(this.repository.findById(sessionId)).isNotNull();
+ }
+
+ /** */
+ @Test
+ void createSessionWithSecurityContextAndFindByPrincipal() {
+ IgniteSession session = this.repository.createSession();
+
+ String username = "saves-" + System.currentTimeMillis();
+ Authentication authentication = new UsernamePasswordAuthenticationToken(username, "password",
+ AuthorityUtils.createAuthorityList("ROLE_USER"));
+ SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+ securityContext.setAuthentication(authentication);
+ session.setAttribute(SPRING_SECURITY_CONTEXT, securityContext);
+
+ this.repository.save(session);
+
+ assertThat(this.repository
+ .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, username))
+ .hasSize(1);
+ }
+
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/EmbeddedIgniteIndexedSessionRepositoryITest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/EmbeddedIgniteIndexedSessionRepositoryITest.java
new file mode 100644
index 0000000..e40fc72
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/EmbeddedIgniteIndexedSessionRepositoryITest.java
@@ -0,0 +1,65 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.Ignition;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+
+/**
+ * Integration tests for {@link IgniteIndexedSessionRepository} using embedded topology.
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration
+@WebAppConfiguration
+public
+class EmbeddedIgniteIndexedSessionRepositoryITest extends AbstractIgniteIndexedSessionRepositoryITest {
+ /** */
+ @BeforeAll
+ static void setUpClass() {
+ Ignition.stopAll(true);
+ }
+
+ /** */
+ @AfterAll
+ static void tearDownClass() {
+ Ignition.stopAll(true);
+ }
+
+ /** */
+ @EnableIgniteHttpSession
+ @Configuration
+ static class IgniteSessionConfig {
+
+ /** */
+ @Bean
+ Ignite ignite() {
+ return IgniteITestUtils.embeddedIgniteServer();
+ }
+
+ }
+
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfigurationTest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfigurationTest.java
new file mode 100644
index 0000000..0e69f4f
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfigurationTest.java
@@ -0,0 +1,495 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+
+import org.springframework.beans.factory.BeanCreationException;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.annotation.Order;
+import org.springframework.session.FlushMode;
+import org.springframework.session.IndexResolver;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.config.SessionRepositoryCustomizer;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link IgniteHttpSessionConfiguration}.
+ */
+public class IgniteHttpSessionConfigurationTest {
+ /** */
+ private static final String MAP_NAME = "spring:test:sessions";
+
+ /** */
+ private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 600;
+
+ /** */
+ private AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
+
+ /** */
+ @AfterEach
+ void closeContext() {
+ if (this.context != null)
+ this.context.close();
+ }
+
+ /** */
+ @Test
+ void noIgniteConfiguration() {
+ assertThatExceptionOfType(BeanCreationException.class)
+ .isThrownBy(() -> registerAndRefresh(NoIgniteConfiguration.class)).withMessageContaining("Ignite");
+ }
+
+ /** */
+ @Test
+ void defaultConfiguration() {
+ registerAndRefresh(DefaultConfiguration.class);
+
+ assertThat(this.context.getBean(IgniteIndexedSessionRepository.class)).isNotNull();
+ }
+
+ /** */
+ @Test
+ void customTableName() {
+ registerAndRefresh(CustomSessionMapNameConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ IgniteHttpSessionConfiguration configuration = this.context.getBean(IgniteHttpSessionConfiguration.class);
+ assertThat(repository).isNotNull();
+ assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")).isEqualTo(MAP_NAME);
+ }
+
+ /** */
+ @Test
+ void setCustomSessionMapName() {
+ registerAndRefresh(BaseConfiguration.class, CustomSessionMapNameSetConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ IgniteHttpSessionConfiguration configuration = this.context.getBean(IgniteHttpSessionConfiguration.class);
+ assertThat(repository).isNotNull();
+ assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")).isEqualTo(MAP_NAME);
+ }
+
+ /** */
+ @Test
+ void setCustomMaxInactiveIntervalInSeconds() {
+ registerAndRefresh(BaseConfiguration.class, CustomMaxInactiveIntervalInSecondsSetConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ assertThat(repository).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval"))
+ .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+ }
+
+ /** */
+ @Test
+ void customMaxInactiveIntervalInSeconds() {
+ registerAndRefresh(CustomMaxInactiveIntervalInSecondsConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ assertThat(repository).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval"))
+ .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+ }
+
+ /** */
+ @Test
+ void customFlushImmediately() {
+ registerAndRefresh(CustomFlushImmediatelyConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ assertThat(repository).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "flushMode")).isEqualTo(FlushMode.IMMEDIATE);
+ }
+
+ /** */
+ @Test
+ void setCustomFlushImmediately() {
+ registerAndRefresh(BaseConfiguration.class, CustomFlushImmediatelySetConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ assertThat(repository).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "flushMode")).isEqualTo(FlushMode.IMMEDIATE);
+ }
+
+ /** */
+ @Test
+ void customSaveModeAnnotation() {
+ registerAndRefresh(BaseConfiguration.class, CustomSaveModeExpressionAnnotationConfiguration.class);
+ assertThat(this.context.getBean(IgniteIndexedSessionRepository.class)).hasFieldOrPropertyWithValue("saveMode",
+ SaveMode.ALWAYS);
+ }
+
+ /** */
+ @Test
+ void customSaveModeSetter() {
+ registerAndRefresh(BaseConfiguration.class, CustomSaveModeExpressionSetterConfiguration.class);
+ assertThat(this.context.getBean(IgniteIndexedSessionRepository.class)).hasFieldOrPropertyWithValue("saveMode",
+ SaveMode.ALWAYS);
+ }
+
+ /** */
+ @Test
+ void qualifiedIgniteConfiguration() {
+ registerAndRefresh(QualifiedIgniteConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ Ignite ignite = this.context.getBean("qualifiedIgnite", Ignite.class);
+ assertThat(repository).isNotNull();
+ assertThat(ignite).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+ .isEqualTo(QualifiedIgniteConfiguration.qualifiedIgniteSessions);
+ }
+
+ /** */
+ @Test
+ void primaryIgniteConfiguration() {
+ registerAndRefresh(PrimaryIgniteConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ Ignite ignite = this.context.getBean("primaryIgnite", Ignite.class);
+ assertThat(repository).isNotNull();
+ assertThat(ignite).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+ .isEqualTo(PrimaryIgniteConfiguration.primaryIgniteSessions);
+ }
+
+ /** */
+ @Test
+ void qualifiedAndPrimaryIgniteConfiguration() {
+ registerAndRefresh(QualifiedAndPrimaryIgniteConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ Ignite ignite = this.context.getBean("qualifiedIgnite", Ignite.class);
+ assertThat(repository).isNotNull();
+ assertThat(ignite).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+ .isEqualTo(QualifiedAndPrimaryIgniteConfiguration.qualifiedIgniteSessions);
+ }
+
+ /** */
+ @Test
+ void namedIgniteConfiguration() {
+ registerAndRefresh(NamedIgniteConfiguration.class);
+
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ Ignite ignite = this.context.getBean("ignite", Ignite.class);
+ assertThat(repository).isNotNull();
+ assertThat(ignite).isNotNull();
+ assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+ .isEqualTo(NamedIgniteConfiguration.igniteSessions);
+ }
+
+ /** */
+ @Test
+ void multipleIgniteConfiguration() {
+ assertThatExceptionOfType(BeanCreationException.class)
+ .isThrownBy(() -> registerAndRefresh(MultipleIgniteConfiguration.class))
+ .withMessageContaining("expected single matching bean but found 2");
+ }
+
+ /** */
+ @Test
+ void customIndexResolverConfiguration() {
+ registerAndRefresh(CustomIndexResolverConfiguration.class);
+ IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ @SuppressWarnings("unchecked")
+ IndexResolver<Session> indexResolver = this.context.getBean(IndexResolver.class);
+ assertThat(repository).isNotNull();
+ assertThat(indexResolver).isNotNull();
+ assertThat(repository).hasFieldOrPropertyWithValue("indexResolver", indexResolver);
+ }
+
+ /** */
+ @Test
+ void sessionRepositoryCustomizer() {
+ registerAndRefresh(SessionRepositoryCustomizerConfiguration.class);
+ IgniteIndexedSessionRepository sessionRepository = this.context.getBean(IgniteIndexedSessionRepository.class);
+ assertThat(sessionRepository).hasFieldOrPropertyWithValue("defaultMaxInactiveInterval",
+ MAX_INACTIVE_INTERVAL_IN_SECONDS);
+ }
+
+ /** */
+ private void registerAndRefresh(Class<?>... annotatedClasses) {
+ this.context.register(annotatedClasses);
+ this.context.refresh();
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class NoIgniteConfiguration {
+
+ }
+
+ /** */
+ static class BaseConfiguration {
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> defaultIgniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @Bean
+ Ignite defaultIgnite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(defaultIgniteSessions);
+ return ignite;
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class DefaultConfiguration extends BaseConfiguration {
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession(sessionMapName = MAP_NAME)
+ static class CustomSessionMapNameConfiguration extends BaseConfiguration {
+
+ }
+
+ /** */
+ @Configuration
+ static class CustomSessionMapNameSetConfiguration extends IgniteHttpSessionConfiguration {
+
+ CustomSessionMapNameSetConfiguration() {
+ setSessionMapName(MAP_NAME);
+ }
+
+ }
+
+ /** */
+ @Configuration
+ static class CustomMaxInactiveIntervalInSecondsSetConfiguration extends IgniteHttpSessionConfiguration {
+
+ CustomMaxInactiveIntervalInSecondsSetConfiguration() {
+ setMaxInactiveIntervalInSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS)
+ static class CustomMaxInactiveIntervalInSecondsConfiguration extends BaseConfiguration {
+
+ }
+
+ /** */
+ @Configuration
+ static class CustomFlushImmediatelySetConfiguration extends IgniteHttpSessionConfiguration {
+
+ CustomFlushImmediatelySetConfiguration() {
+ setFlushMode(FlushMode.IMMEDIATE);
+ }
+
+ }
+
+ /** */
+ @EnableIgniteHttpSession(saveMode = SaveMode.ALWAYS)
+ static class CustomSaveModeExpressionAnnotationConfiguration {
+
+ }
+
+ /** */
+ @Configuration
+ static class CustomSaveModeExpressionSetterConfiguration extends IgniteHttpSessionConfiguration {
+
+ CustomSaveModeExpressionSetterConfiguration() {
+ setSaveMode(SaveMode.ALWAYS);
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession(flushMode = FlushMode.IMMEDIATE)
+ static class CustomFlushImmediatelyConfiguration extends BaseConfiguration {
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class QualifiedIgniteConfiguration extends BaseConfiguration {
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> qualifiedIgniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @Bean
+ @SpringSessionIgnite
+ Ignite qualifiedIgnite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(qualifiedIgniteSessions);
+ return ignite;
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class PrimaryIgniteConfiguration extends BaseConfiguration {
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> primaryIgniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @Bean
+ @Primary
+ Ignite primaryIgnite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(primaryIgniteSessions);
+ return ignite;
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class QualifiedAndPrimaryIgniteConfiguration extends BaseConfiguration {
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> qualifiedIgniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> primaryIgniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @Bean
+ @SpringSessionIgnite
+ Ignite qualifiedIgnite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(qualifiedIgniteSessions);
+ return ignite;
+ }
+
+ /** */
+ @Bean
+ @Primary
+ Ignite primaryIgnite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(primaryIgniteSessions);
+ return ignite;
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class NamedIgniteConfiguration extends BaseConfiguration {
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> igniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @Bean
+ Ignite ignite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(igniteSessions);
+ return ignite;
+ }
+
+ }
+
+ /** */
+ @Configuration
+ @EnableIgniteHttpSession
+ static class MultipleIgniteConfiguration extends BaseConfiguration {
+
+ /** */
+ @SuppressWarnings("unchecked")
+ static IgniteCache<Object, Object> secondaryIgniteSessions = mock(IgniteCache.class);
+
+ /** */
+ @Bean
+ Ignite secondaryIgnite() {
+ Ignite ignite = mock(Ignite.class);
+ given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+ .willReturn(secondaryIgniteSessions);
+ return ignite;
+ }
+
+ }
+
+ /** */
+ @EnableIgniteHttpSession
+ static class CustomIndexResolverConfiguration extends BaseConfiguration {
+
+ /** */
+ @Bean
+ @SuppressWarnings("unchecked")
+ IndexResolver<Session> indexResolver() {
+ return mock(IndexResolver.class);
+ }
+
+ }
+
+ /** */
+ @EnableIgniteHttpSession
+ static class SessionRepositoryCustomizerConfiguration extends BaseConfiguration {
+
+ /** */
+ @Bean
+ @Order(0)
+ SessionRepositoryCustomizer<IgniteIndexedSessionRepository> sessionRepositoryCustomizerOne() {
+ return (sessionRepository) -> sessionRepository.setDefaultMaxInactiveInterval(0);
+ }
+
+ /** */
+ @Bean
+ @Order(1)
+ SessionRepositoryCustomizer<IgniteIndexedSessionRepository> sessionRepositoryCustomizerTwo() {
+ return (sessionRepository) -> sessionRepository
+ .setDefaultMaxInactiveInterval(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+ }
+ }
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteITestUtils.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteITestUtils.java
new file mode 100644
index 0000000..f0b2a94
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteITestUtils.java
@@ -0,0 +1,37 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.Ignition;
+
+/**
+ * Utility class for Ignite integration tests.
+ */
+final class IgniteITestUtils {
+ private IgniteITestUtils() {
+ }
+
+ /**
+ * Creates {@link Ignite} for use in integration tests.
+ * @return the Ignite instance
+ */
+ static Ignite embeddedIgniteServer() {
+ return Ignition.start();
+ }
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepositoryTest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepositoryTest.java
new file mode 100644
index 0000000..61f4eb8
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepositoryTest.java
@@ -0,0 +1,483 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import javax.cache.expiry.TouchedExpiryPolicy;
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.cache.query.FieldsQueryCursor;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.session.FindByIndexNameSessionRepository;
+import org.springframework.session.FlushMode;
+import org.springframework.session.MapSession;
+import org.apache.ignite.spring.sessions.IgniteIndexedSessionRepository.IgniteSession;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link IgniteIndexedSessionRepository}.
+ */
+public class IgniteIndexedSessionRepositoryTest {
+ /** */
+ private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
+
+ /** */
+ private final Ignite ignite = mock(Ignite.class);
+
+ /** */
+ @SuppressWarnings("unchecked")
+ private final IgniteCache<String, IgniteSession> sessions = mock(IgniteCache.class);
+
+ /** */
+ private IgniteIndexedSessionRepository repository;
+
+ /** */
+ @BeforeEach
+ void setUp() {
+ given(this.ignite.<String, IgniteSession>getOrCreateCache(
+ ArgumentMatchers.<CacheConfiguration<String, IgniteSession>>any())).willReturn(this.sessions);
+ given(this.sessions.withExpiryPolicy(ArgumentMatchers.any())).willReturn(this.sessions);
+ this.repository = new IgniteIndexedSessionRepository(this.ignite);
+ this.repository.init();
+ }
+
+ /** */
+ @Test
+ void constructorNullIgnite() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new IgniteIndexedSessionRepository(null))
+ .withMessage("Ignite must not be null");
+ }
+
+ /** */
+ @Test
+ void setSaveModeNull() {
+ assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSaveMode(null))
+ .withMessage("saveMode must not be null");
+ }
+
+ /** */
+ @Test
+ void createSessionDefaultMaxInactiveInterval() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+
+ assertThat(session.getMaxInactiveInterval()).isEqualTo(new MapSession().getMaxInactiveInterval());
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void createSessionCustomMaxInactiveInterval() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ int interval = 1;
+ this.repository.setDefaultMaxInactiveInterval(interval);
+
+ IgniteSession session = this.repository.createSession();
+
+ assertThat(session.getMaxInactiveInterval()).isEqualTo(Duration.ofSeconds(interval));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveNewFlushModeOnSave() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+ verifyNoMoreInteractions(this.sessions);
+
+ this.repository.save(session);
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveNewFlushModeImmediate() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+ IgniteSession session = this.repository.createSession();
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUpdatedAttributeFlushModeOnSave() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+ session.setAttribute("testName", "testValue");
+ verifyNoMoreInteractions(this.sessions);
+
+ this.repository.save(session);
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ @Test
+ void saveUpdatedAttributeFlushModeImmediate() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+ IgniteSession session = this.repository.createSession();
+ session.setAttribute("testName", "testValue");
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).replace(eq(session.getId()), eq(session));
+
+ this.repository.save(session);
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void removeAttributeFlushModeOnSave() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+ session.removeAttribute("testName");
+ verifyNoMoreInteractions(this.sessions);
+
+ this.repository.save(session);
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void removeAttributeFlushModeImmediate() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+ IgniteSession session = this.repository.createSession();
+ session.removeAttribute("testName");
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).replace(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+ this.repository.save(session);
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUpdatedLastAccessedTimeFlushModeOnSave() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+ session.setLastAccessedTime(Instant.now());
+ verifyNoMoreInteractions(this.sessions);
+
+ this.repository.save(session);
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUpdatedLastAccessedTimeFlushModeImmediate() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+ IgniteSession session = this.repository.createSession();
+ session.setLastAccessedTime(Instant.now());
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).replace(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+ this.repository.save(session);
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUpdatedMaxInactiveIntervalInSecondsFlushModeOnSave() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+ session.setMaxInactiveInterval(Duration.ofSeconds(1));
+ verifyNoMoreInteractions(this.sessions);
+
+ this.repository.save(session);
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUpdatedMaxInactiveIntervalInSecondsFlushModeImmediate() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+ IgniteSession session = this.repository.createSession();
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+ String sessionId = session.getId();
+ session.setMaxInactiveInterval(Duration.ofSeconds(1));
+ verify(this.sessions, times(1)).put(eq(sessionId), eq(session));
+ verify(this.sessions, times(1)).replace(eq(sessionId), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+ this.repository.save(session);
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUnchangedFlushModeOnSave() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession session = this.repository.createSession();
+ this.repository.save(session);
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+ this.repository.save(session);
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void saveUnchangedFlushModeImmediate() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+ IgniteSession session = this.repository.createSession();
+ verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+ verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+ this.repository.save(session);
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void getSessionNotFound() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ String sessionId = "testSessionId";
+
+ IgniteSession session = this.repository.findById(sessionId);
+
+ assertThat(session).isNull();
+ verify(this.sessions, times(1)).get(eq(sessionId));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void getSessionExpired() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession expired = this.repository.new IgniteSession(new MapSession(), true);
+
+ expired.setLastAccessedTime(Instant.now().minusSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS + 1));
+ given(this.sessions.get(eq(expired.getId()))).willReturn(expired);
+
+ IgniteSession session = this.repository.findById(expired.getId());
+
+ assertThat(session).isNull();
+ verify(this.sessions, times(1)).get(eq(expired.getId()));
+ verify(this.sessions, times(1)).remove(eq(expired.getId()));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void getSessionFound() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ IgniteSession saved = this.repository.new IgniteSession(new MapSession(), true);
+ saved.setAttribute("savedName", "savedValue");
+ given(this.sessions.get(eq(saved.getId()))).willReturn(saved);
+
+ IgniteSession session = this.repository.findById(saved.getId());
+
+ assertThat(session.getId()).isEqualTo(saved.getId());
+ assertThat(session.<String>getAttribute("savedName")).isEqualTo("savedValue");
+ verify(this.sessions, times(1)).get(eq(saved.getId()));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void delete() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ String sessionId = "testSessionId";
+
+ this.repository.deleteById(sessionId);
+
+ verify(this.sessions, times(1)).remove(eq(sessionId));
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void findByIndexNameAndIndexValueUnknownIndexName() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ String indexValue = "testIndexValue";
+
+ Map<String, IgniteSession> sessions = this.repository.findByIndexNameAndIndexValue("testIndexName", indexValue);
+
+ assertThat(sessions).isEmpty();
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void findByIndexNameAndIndexValuePrincipalIndexNameNotFound() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ String principal = "username";
+
+ Map<String, IgniteSession> sessions = this.repository
+ .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal);
+
+ verify(this.sessions, times(1)).query(ArgumentMatchers
+ .argThat((argument) -> ("SELECT * FROM IgniteSession WHERE principal='" + principal + "'")
+ .equals(argument.getSql())));
+
+ assertThat(sessions).isEmpty();
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void findByIndexNameAndIndexValuePrincipalIndexNameFound() {
+ verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+ String principal = "username";
+ Authentication authentication = new UsernamePasswordAuthenticationToken(principal, "notused",
+ AuthorityUtils.createAuthorityList("ROLE_USER"));
+
+ List<Object> saved = new ArrayList<>(2);
+
+ final MapSession ses1 = new MapSession();
+ ses1.setAttribute(SPRING_SECURITY_CONTEXT, authentication);
+ IgniteSession saved1 = this.repository.new IgniteSession(ses1, true);
+ saved.add(Arrays.asList(ses1, authentication.getPrincipal()));
+ final MapSession ses2 = new MapSession();
+ ses2.setAttribute(SPRING_SECURITY_CONTEXT, authentication);
+ IgniteSession saved2 = this.repository.new IgniteSession(ses2, true);
+ saved.add(Arrays.asList(ses2, authentication.getPrincipal()));
+
+ given(this.sessions.query(ArgumentMatchers.any())).willReturn(new FieldsQueryCursor<List<?>>() {
+ /** */
+ @Override
+ public String getFieldName(int idx) {
+ return null;
+ }
+
+ /** */
+ @Override
+ public int getColumnsCount() {
+ return 2;
+ }
+
+ /** */
+ @Override
+ public List<List<?>> getAll() {
+ return (List) saved;
+ }
+
+ /** */
+ @Override
+ public void close() {
+
+ }
+
+ /** */
+ @NotNull
+ @Override
+ public Iterator<List<?>> iterator() {
+ return (Iterator) saved.iterator();
+ }
+ });
+
+ Map<String, IgniteSession> sessions = this.repository
+ .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal);
+
+ assertThat(sessions).hasSize(2);
+ verify(this.sessions, times(1)).query(any());
+ verifyNoMoreInteractions(this.sessions);
+ }
+
+ /** */
+ @Test
+ void getAttributeNamesAndRemove() {
+ IgniteSession session = this.repository.createSession();
+ session.setAttribute("attribute1", "value1");
+ session.setAttribute("attribute2", "value2");
+
+ for (String attributeName : session.getAttributeNames()) {
+ session.removeAttribute(attributeName);
+ }
+
+ assertThat(session.getAttributeNames()).isEmpty();
+ }
+
+ /** */
+ private static TouchedExpiryPolicy createExpiryPolicy(IgniteSession session) {
+ return new TouchedExpiryPolicy(
+ new javax.cache.expiry.Duration(TimeUnit.SECONDS, session.getMaxInactiveInterval().getSeconds()));
+ }
+
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/testsuites/IgniteSpringSessionTestSuite.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/testsuites/IgniteSpringSessionTestSuite.java
new file mode 100644
index 0000000..880119e
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/testsuites/IgniteSpringSessionTestSuite.java
@@ -0,0 +1,19 @@
+package org.apache.ignite.testsuites;
+
+import org.apache.ignite.spring.sessions.EmbeddedIgniteIndexedSessionRepositoryITest;
+import org.apache.ignite.spring.sessions.IgniteHttpSessionConfigurationTest;
+import org.apache.ignite.spring.sessions.IgniteIndexedSessionRepositoryTest;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/**
+ * Ignite Spring Session Test Suite
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ EmbeddedIgniteIndexedSessionRepositoryITest.class,
+ IgniteHttpSessionConfigurationTest.class,
+ IgniteIndexedSessionRepositoryTest.class
+})
+public class IgniteSpringSessionTestSuite {
+}
diff --git a/pom.xml b/pom.xml
index 8682549..27249de 100644
--- a/pom.xml
+++ b/pom.xml
@@ -62,6 +62,7 @@
<module>modules/performance-statistics-ext</module>
<module>modules/spring-tx-ext</module>
<module>modules/spring-cache-ext</module>
+ <module>modules/spring-session-ext</module>
</modules>
<profiles>