Skip to content

Commit cdd74c9

Browse files
committed
fix: resolve TTL not being set with spring-boot-devtools (#458)
- Add ClassLoaderAwareKeyspaceResolver to handle cross-class-loader entity lookups - Store keyspace settings by class name for compatibility with RestartClassLoader - Update SimpleRedisDocumentRepository and RedisJSONKeyValueAdapter to use resolver - Ensure TTL settings work correctly in @PostConstruct and when devtools is active The issue occurred because spring-boot-devtools uses a different class loader (RestartClassLoader) which caused entity classes to not be recognized by the KeyspaceConfiguration when comparing Class instances directly. This fix stores and retrieves settings by fully qualified class name as a fallback. Fixes #458
1 parent fe7f287 commit cdd74c9

File tree

6 files changed

+294
-7
lines changed

6 files changed

+294
-7
lines changed

redis-om-spring/src/main/java/com/redis/om/spring/RedisJSONKeyValueAdapter.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,21 @@ private Optional<Long> getTTLForEntity(Object entity) {
439439
entityClassKey = entity.getClass();
440440
}
441441

442-
KeyspaceConfiguration keyspaceConfig = mappingContext.getMappingConfiguration().getKeyspaceConfiguration();
443-
if (keyspaceConfig.hasSettingsFor(entityClassKey)) {
444-
var settings = keyspaceConfig.getKeyspaceSettings(entityClassKey);
442+
// Use the resolver if available for cross-class-loader compatibility
443+
KeyspaceConfiguration.KeyspaceSettings settings = null;
444+
if (mappingContext instanceof com.redis.om.spring.mapping.RedisEnhancedMappingContext) {
445+
var resolver = ((com.redis.om.spring.mapping.RedisEnhancedMappingContext) mappingContext).getKeyspaceResolver();
446+
if (resolver.hasSettingsFor(entityClassKey)) {
447+
settings = resolver.getKeyspaceSettings(entityClassKey);
448+
}
449+
} else {
450+
KeyspaceConfiguration keyspaceConfig = mappingContext.getMappingConfiguration().getKeyspaceConfiguration();
451+
if (keyspaceConfig.hasSettingsFor(entityClassKey)) {
452+
settings = keyspaceConfig.getKeyspaceSettings(entityClassKey);
453+
}
454+
}
455+
456+
if (settings != null) {
445457
if (StringUtils.hasText(settings.getTimeToLivePropertyName())) {
446458
Method ttlGetter;
447459
try {

redis-om-spring/src/main/java/com/redis/om/spring/indexing/RediSearchIndexer.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1187,7 +1187,13 @@ private void updateTTLSettings(Class<?> cl, String entityPrefix, boolean isDocum
11871187
allClassFields.stream().filter(field -> field.isAnnotationPresent(TimeToLive.class)).findFirst().ifPresent(
11881188
field -> setting.setTimeToLivePropertyName(field.getName()));
11891189

1190-
mappingContext.getMappingConfiguration().getKeyspaceConfiguration().addKeyspaceSettings(setting);
1190+
// Use the resolver if the mapping context is enhanced
1191+
if (mappingContext instanceof com.redis.om.spring.mapping.RedisEnhancedMappingContext) {
1192+
((com.redis.om.spring.mapping.RedisEnhancedMappingContext) mappingContext).getKeyspaceResolver()
1193+
.addKeyspaceSettings(cl, setting);
1194+
} else {
1195+
mappingContext.getMappingConfiguration().getKeyspaceConfiguration().addKeyspaceSettings(setting);
1196+
}
11911197
}
11921198
}
11931199

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.redis.om.spring.mapping;
2+
3+
import java.util.Map;
4+
import java.util.concurrent.ConcurrentHashMap;
5+
6+
import org.springframework.data.redis.core.convert.KeyspaceConfiguration;
7+
import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings;
8+
9+
/**
10+
* A resolver that handles KeyspaceSettings lookups across different class loaders.
11+
* <p>
12+
* This class addresses the issue where spring-boot-devtools uses a different class loader
13+
* (RestartClassLoader) which causes entity classes to not be recognized by the
14+
* KeyspaceConfiguration when comparing Class instances directly.
15+
* <p>
16+
* By storing and retrieving settings based on fully qualified class names instead of
17+
* Class instances, this resolver ensures that TTL and other keyspace configurations
18+
* work correctly regardless of the class loader being used.
19+
*
20+
* @since 1.0.0
21+
*/
22+
public class ClassLoaderAwareKeyspaceResolver {
23+
24+
private final KeyspaceConfiguration keyspaceConfiguration;
25+
private final Map<String, KeyspaceSettings> settingsByClassName = new ConcurrentHashMap<>();
26+
27+
/**
28+
* Creates a new resolver wrapping the given KeyspaceConfiguration.
29+
*
30+
* @param keyspaceConfiguration the keyspace configuration to wrap
31+
*/
32+
public ClassLoaderAwareKeyspaceResolver(KeyspaceConfiguration keyspaceConfiguration) {
33+
this.keyspaceConfiguration = keyspaceConfiguration;
34+
}
35+
36+
/**
37+
* Registers keyspace settings for a class.
38+
* <p>
39+
* This method stores settings both by the Class instance (for normal operation)
40+
* and by the fully qualified class name (for cross-class-loader compatibility).
41+
*
42+
* @param entityClass the entity class
43+
* @param settings the keyspace settings for the class
44+
*/
45+
public void addKeyspaceSettings(Class<?> entityClass, KeyspaceSettings settings) {
46+
// Store by class name for cross-class-loader lookup
47+
settingsByClassName.put(entityClass.getName(), settings);
48+
// Also add to the original configuration for backward compatibility
49+
keyspaceConfiguration.addKeyspaceSettings(settings);
50+
}
51+
52+
/**
53+
* Checks if settings exist for the given class.
54+
* <p>
55+
* This method first checks the original KeyspaceConfiguration, then falls back
56+
* to checking by class name if not found. This handles the case where the class
57+
* was loaded by a different class loader.
58+
*
59+
* @param entityClass the entity class to check
60+
* @return true if settings exist for the class, false otherwise
61+
*/
62+
public boolean hasSettingsFor(Class<?> entityClass) {
63+
// First try the normal lookup
64+
if (keyspaceConfiguration.hasSettingsFor(entityClass)) {
65+
return true;
66+
}
67+
// Fall back to class name lookup for cross-class-loader compatibility
68+
return settingsByClassName.containsKey(entityClass.getName());
69+
}
70+
71+
/**
72+
* Gets the keyspace settings for the given class.
73+
* <p>
74+
* This method first attempts to get settings from the original KeyspaceConfiguration,
75+
* then falls back to retrieving by class name if not found. This handles the case
76+
* where the class was loaded by a different class loader.
77+
*
78+
* @param entityClass the entity class
79+
* @return the keyspace settings, or null if not found
80+
*/
81+
public KeyspaceSettings getKeyspaceSettings(Class<?> entityClass) {
82+
// First try the normal lookup
83+
if (keyspaceConfiguration.hasSettingsFor(entityClass)) {
84+
return keyspaceConfiguration.getKeyspaceSettings(entityClass);
85+
}
86+
// Fall back to class name lookup for cross-class-loader compatibility
87+
return settingsByClassName.get(entityClass.getName());
88+
}
89+
90+
/**
91+
* Gets the underlying KeyspaceConfiguration.
92+
*
93+
* @return the wrapped keyspace configuration
94+
*/
95+
public KeyspaceConfiguration getKeyspaceConfiguration() {
96+
return keyspaceConfiguration;
97+
}
98+
}

redis-om-spring/src/main/java/com/redis/om/spring/mapping/RedisEnhancedMappingContext.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class RedisEnhancedMappingContext extends RedisMappingContext {
3838
private static final Log logger = LogFactory.getLog(RedisEnhancedMappingContext.class);
3939
private final MappingConfiguration mappingConfiguration;
4040
private final TimeToLiveAccessor timeToLiveAccessor;
41+
private final ClassLoaderAwareKeyspaceResolver keyspaceResolver;
4142

4243
/**
4344
* Creates a new {@code RedisEnhancedMappingContext} with the specified mapping configuration.
@@ -51,6 +52,7 @@ public class RedisEnhancedMappingContext extends RedisMappingContext {
5152
public RedisEnhancedMappingContext(MappingConfiguration mappingConfiguration) {
5253
super(mappingConfiguration);
5354
this.mappingConfiguration = mappingConfiguration;
55+
this.keyspaceResolver = new ClassLoaderAwareKeyspaceResolver(mappingConfiguration.getKeyspaceConfiguration());
5456
this.timeToLiveAccessor = new RedisEnhancedTimeToLiveAccessor(mappingConfiguration.getKeyspaceConfiguration(),
5557
this);
5658
}
@@ -71,4 +73,13 @@ public RedisEnhancedMappingContext() {
7173
protected <T> RedisPersistentEntity<T> createPersistentEntity(TypeInformation<T> typeInformation) {
7274
return new RedisEnhancedPersistentEntity<>(typeInformation, getKeySpaceResolver(), timeToLiveAccessor);
7375
}
76+
77+
/**
78+
* Gets the class loader aware keyspace resolver.
79+
*
80+
* @return the keyspace resolver that handles cross-class-loader lookups
81+
*/
82+
public ClassLoaderAwareKeyspaceResolver getKeyspaceResolver() {
83+
return keyspaceResolver;
84+
}
7485
}

redis-om-spring/src/main/java/com/redis/om/spring/repository/support/SimpleRedisDocumentRepository.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -521,9 +521,21 @@ private void processReferenceAnnotations(byte[] objectKey, Object entity, Pipeli
521521
* @return an {@link Optional} containing the TTL in seconds, or empty if no TTL is configured
522522
*/
523523
private Optional<Long> getTTLForEntity(Object entity) {
524-
KeyspaceConfiguration keyspaceConfig = mappingContext.getMappingConfiguration().getKeyspaceConfiguration();
525-
if (keyspaceConfig.hasSettingsFor(entity.getClass())) {
526-
var settings = keyspaceConfig.getKeyspaceSettings(entity.getClass());
524+
// Use the resolver if available for cross-class-loader compatibility
525+
KeyspaceConfiguration.KeyspaceSettings settings = null;
526+
if (mappingContext instanceof com.redis.om.spring.mapping.RedisEnhancedMappingContext) {
527+
var resolver = ((com.redis.om.spring.mapping.RedisEnhancedMappingContext) mappingContext).getKeyspaceResolver();
528+
if (resolver.hasSettingsFor(entity.getClass())) {
529+
settings = resolver.getKeyspaceSettings(entity.getClass());
530+
}
531+
} else {
532+
KeyspaceConfiguration keyspaceConfig = mappingContext.getMappingConfiguration().getKeyspaceConfiguration();
533+
if (keyspaceConfig.hasSettingsFor(entity.getClass())) {
534+
settings = keyspaceConfig.getKeyspaceSettings(entity.getClass());
535+
}
536+
}
537+
538+
if (settings != null) {
527539

528540
if (org.springframework.util.StringUtils.hasText(settings.getTimeToLivePropertyName())) {
529541
Method ttlGetter;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package com.redis.om.spring.mapping;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import org.junit.jupiter.api.BeforeEach;
6+
import org.junit.jupiter.api.Test;
7+
import org.springframework.data.redis.core.convert.KeyspaceConfiguration;
8+
import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings;
9+
10+
import com.redis.om.spring.annotations.Document;
11+
12+
class ClassLoaderAwareKeyspaceResolverTest {
13+
14+
private ClassLoaderAwareKeyspaceResolver resolver;
15+
private KeyspaceConfiguration keyspaceConfiguration;
16+
17+
@BeforeEach
18+
void setUp() {
19+
keyspaceConfiguration = new KeyspaceConfiguration();
20+
resolver = new ClassLoaderAwareKeyspaceResolver(keyspaceConfiguration);
21+
}
22+
23+
@Test
24+
void testNormalClassLoaderLookup() {
25+
// Given
26+
Class<?> entityClass = TestEntity.class;
27+
KeyspaceSettings settings = new KeyspaceSettings(entityClass, "test:");
28+
settings.setTimeToLive(300L);
29+
30+
// When
31+
resolver.addKeyspaceSettings(entityClass, settings);
32+
33+
// Then
34+
assertThat(resolver.hasSettingsFor(entityClass)).isTrue();
35+
KeyspaceSettings retrieved = resolver.getKeyspaceSettings(entityClass);
36+
assertThat(retrieved).isNotNull();
37+
assertThat(retrieved.getTimeToLive()).isEqualTo(300L);
38+
}
39+
40+
@Test
41+
void testCrossClassLoaderLookupByName() {
42+
// Given
43+
Class<?> entityClass = TestEntity.class;
44+
KeyspaceSettings settings = new KeyspaceSettings(entityClass, "test:");
45+
settings.setTimeToLive(600L);
46+
47+
// When
48+
resolver.addKeyspaceSettings(entityClass, settings);
49+
50+
// Then - simulate a different class loader by using a class with the same name
51+
// In reality, this would be a different Class instance with the same name
52+
// For testing, we verify the name-based lookup works
53+
assertThat(resolver.hasSettingsFor(TestEntity.class)).isTrue();
54+
KeyspaceSettings retrieved = resolver.getKeyspaceSettings(TestEntity.class);
55+
assertThat(retrieved).isNotNull();
56+
assertThat(retrieved.getTimeToLive()).isEqualTo(600L);
57+
}
58+
59+
@Test
60+
void testMultipleEntities() {
61+
// Given
62+
Class<?> entity1 = TestEntity.class;
63+
Class<?> entity2 = AnotherTestEntity.class;
64+
65+
KeyspaceSettings settings1 = new KeyspaceSettings(entity1, "test1:");
66+
settings1.setTimeToLive(100L);
67+
68+
KeyspaceSettings settings2 = new KeyspaceSettings(entity2, "test2:");
69+
settings2.setTimeToLive(200L);
70+
71+
// When
72+
resolver.addKeyspaceSettings(entity1, settings1);
73+
resolver.addKeyspaceSettings(entity2, settings2);
74+
75+
// Then
76+
assertThat(resolver.hasSettingsFor(entity1)).isTrue();
77+
assertThat(resolver.hasSettingsFor(entity2)).isTrue();
78+
79+
assertThat(resolver.getKeyspaceSettings(entity1).getTimeToLive()).isEqualTo(100L);
80+
assertThat(resolver.getKeyspaceSettings(entity2).getTimeToLive()).isEqualTo(200L);
81+
}
82+
83+
@Test
84+
void testNonExistentEntity() {
85+
// Given
86+
Class<?> entityClass = NonExistentEntity.class;
87+
88+
// Then
89+
assertThat(resolver.hasSettingsFor(entityClass)).isFalse();
90+
assertThat(resolver.getKeyspaceSettings(entityClass)).isNull();
91+
}
92+
93+
@Document(timeToLive = 300)
94+
static class TestEntity {
95+
private String id;
96+
private String name;
97+
98+
public String getId() {
99+
return id;
100+
}
101+
102+
public void setId(String id) {
103+
this.id = id;
104+
}
105+
106+
public String getName() {
107+
return name;
108+
}
109+
110+
public void setName(String name) {
111+
this.name = name;
112+
}
113+
}
114+
115+
@Document(timeToLive = 200)
116+
static class AnotherTestEntity {
117+
private String id;
118+
private String value;
119+
120+
public String getId() {
121+
return id;
122+
}
123+
124+
public void setId(String id) {
125+
this.id = id;
126+
}
127+
128+
public String getValue() {
129+
return value;
130+
}
131+
132+
public void setValue(String value) {
133+
this.value = value;
134+
}
135+
}
136+
137+
static class NonExistentEntity {
138+
private String id;
139+
140+
public String getId() {
141+
return id;
142+
}
143+
144+
public void setId(String id) {
145+
this.id = id;
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)