Skip to content

Commit

Permalink
feature: Support @timetolive on JSON-mapped objects #66 (#68)
Browse files Browse the repository at this point in the history
* feature: add ability for repositories to retrieve TTL for an object

* feature: enable @timetolive annotation and @document timeToLive property for JSON-mapped entities

* test: add TTL tests for @RedisHash
  • Loading branch information
bsbodden authored Jul 12, 2022
1 parent 59585ac commit 065ba0e
Show file tree
Hide file tree
Showing 20 changed files with 397 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.redis.om.spring;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
Expand All @@ -9,6 +10,7 @@
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import org.apache.commons.logging.Log;
Expand All @@ -20,30 +22,23 @@
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisKeyValueAdapter;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.convert.KeyspaceConfiguration;
import org.springframework.data.redis.core.convert.RedisCustomConversions;
import org.springframework.data.redis.core.mapping.RedisMappingContext;
import org.springframework.lang.Nullable;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

import com.google.common.base.Optional;
import com.redis.om.spring.ops.json.JSONOperations;
import com.redis.om.spring.util.ObjectUtils;

public class RedisJSONKeyValueAdapter extends RedisKeyValueAdapter {
private static final Log logger = LogFactory.getLog(RedisJSONKeyValueAdapter.class);
private JSONOperations<?> redisJSONOperations;
private RedisOperations<?, ?> redisOperations;

/**
* Creates new {@link RedisKeyValueAdapter} with default
* {@link RedisMappingContext} and default {@link RedisCustomConversions}.
*
* @param redisOps must not be {@literal null}.
* @param redisJSONOperations must not be {@literal null}.
*/
public RedisJSONKeyValueAdapter(RedisOperations<?, ?> redisOps, JSONOperations<?> redisJSONOperations) {
super(redisOps, new RedisMappingContext());
this.redisJSONOperations = redisJSONOperations;
this.redisOperations = redisOps;
}
private RedisMappingContext mappingContext;

/**
* Creates new {@link RedisKeyValueAdapter} with default
Expand All @@ -58,6 +53,7 @@ public RedisJSONKeyValueAdapter(RedisOperations<?, ?> redisOps, JSONOperations<?
super(redisOps, mappingContext, new RedisCustomConversions());
this.redisJSONOperations = redisJSONOperations;
this.redisOperations = redisOps;
this.mappingContext = mappingContext;
}

/* (non-Javadoc)
Expand All @@ -74,10 +70,15 @@ public Object put(Object id, Object item, String keyspace) {
String key = getKey(keyspace, id);

processAuditAnnotations(key, item);
Optional<Long> maybeTtl = getTTLForEntity(item);

ops.set(key, item);

redisOperations.execute((RedisCallback<Object>) connection -> {

if (maybeTtl.isPresent()) {
connection.expire(toBytes(key), maybeTtl.get());
}
connection.sAdd(toBytes(keyspace), toBytes(id));
return null;
});
Expand Down Expand Up @@ -184,4 +185,36 @@ private void processAuditAnnotations(String key, Object item) {
protected String getKey(String keyspace, Object id) {
return String.format("%s:%s", keyspace, id);
}

private Optional<Long> getTTLForEntity(Object entity) {
KeyspaceConfiguration keyspaceConfig = mappingContext.getMappingConfiguration().getKeyspaceConfiguration();
if (keyspaceConfig.hasSettingsFor(entity.getClass())) {
var settings = keyspaceConfig.getKeyspaceSettings(entity.getClass());

if (StringUtils.hasText(settings.getTimeToLivePropertyName())) {
Method ttlGetter;
try {
Field fld = ReflectionUtils.findField(entity.getClass(), settings.getTimeToLivePropertyName());
ttlGetter = ObjectUtils.getGetterForField(entity.getClass(), fld);
Long ttlPropertyValue = ((Number)ReflectionUtils.invokeMethod(ttlGetter, entity)).longValue();

ReflectionUtils.invokeMethod(ttlGetter, entity);

if (ttlPropertyValue != null) {
TimeToLive ttl = (TimeToLive) fld.getAnnotation(TimeToLive.class);
if (!ttl.unit().equals(TimeUnit.SECONDS)) {
return Optional.of(TimeUnit.SECONDS.convert(ttlPropertyValue, ttl.unit()));
} else {
return Optional.of(ttlPropertyValue);
}
}
} catch (SecurityException | IllegalArgumentException e) {
return Optional.absent();
}
} else if (settings != null && settings.getTimeToLive() != null && settings.getTimeToLive() > 0) {
return Optional.of(settings.getTimeToLive());
}
}
return Optional.absent();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.convert.KeyspaceConfiguration.KeyspaceSettings;
import org.springframework.data.redis.core.mapping.RedisMappingContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.data.util.ClassTypeInformation;
Expand Down Expand Up @@ -115,21 +117,19 @@ BloomOperations<?> redisBloomOperations(RedisModulesOperations<?> redisModulesOp

@Bean(name = "redisJSONKeyValueAdapter")
RedisJSONKeyValueAdapter getRedisJSONKeyValueAdapter(RedisOperations<?, ?> redisOps,
JSONOperations<?> redisJSONOperations) {
return new RedisJSONKeyValueAdapter(redisOps, redisJSONOperations);
JSONOperations<?> redisJSONOperations, RedisMappingContext mappingContext) {
return new RedisJSONKeyValueAdapter(redisOps, redisJSONOperations, mappingContext);
}

@Bean(name = "redisJSONKeyValueTemplate")
public CustomRedisKeyValueTemplate getRedisJSONKeyValueTemplate(RedisOperations<?, ?> redisOps,
JSONOperations<?> redisJSONOperations) {
RedisMappingContext mappingContext = new RedisMappingContext();
return new CustomRedisKeyValueTemplate(getRedisJSONKeyValueAdapter(redisOps, redisJSONOperations), mappingContext);
JSONOperations<?> redisJSONOperations, RedisMappingContext mappingContext) {
return new CustomRedisKeyValueTemplate(getRedisJSONKeyValueAdapter(redisOps, redisJSONOperations, mappingContext), mappingContext);
}

@Bean(name = "redisCustomKeyValueTemplate")
public CustomRedisKeyValueTemplate getKeyValueTemplate(RedisOperations<?, ?> redisOps,
JSONOperations<?> redisJSONOperations) {
RedisMappingContext mappingContext = new RedisMappingContext();
JSONOperations<?> redisJSONOperations, RedisMappingContext mappingContext) {
return new CustomRedisKeyValueTemplate(new RedisEnhancedKeyValueAdapter(redisOps), mappingContext);
}

Expand Down Expand Up @@ -180,6 +180,8 @@ private void createIndicesFor(Class<?> cls, ApplicationContext ac) {
RedisModulesOperations<String> rmo = (RedisModulesOperations<String>) ac
.getBean("redisModulesOperations");

RedisMappingContext mappingContext = (RedisMappingContext)ac.getBean("keyValueMappingContext");

Set<BeanDefinition> beanDefs = new HashSet<BeanDefinition>();
beanDefs.addAll(getBeanDefinitionsFor(ac, cls));

Expand Down Expand Up @@ -214,7 +216,7 @@ private void createIndicesFor(Class<?> cls, ApplicationContext ac) {

IndexDefinition index = new IndexDefinition(
cls == Document.class ? IndexDefinition.Type.JSON : IndexDefinition.Type.HASH);

String entityPrefix = cl.getName() + ":";

if (cl.isAnnotationPresent(Document.class)) {
Expand Down Expand Up @@ -247,6 +249,27 @@ private void createIndicesFor(Class<?> cls, ApplicationContext ac) {
IndexOptions ops = Client.IndexOptions.defaultOptions().setDefinition(index);
opsForSearch.createIndex(schema, ops);
}


// TTL
if (cl.isAnnotationPresent(Document.class)) {
KeyspaceSettings setting = new KeyspaceSettings(cl, cl.getName() + ":");

// Default TTL
Document document = (Document) cl.getAnnotation(Document.class);
if (document.timeToLive() > 0) {
setting.setTimeToLive(document.timeToLive());
}

for (java.lang.reflect.Field field : cl.getDeclaredFields()) {
// @TimeToLive
if (field.isAnnotationPresent(TimeToLive.class)) {
setting.setTimeToLivePropertyName(field.getName());
}
}

mappingContext.getMappingConfiguration().getKeyspaceConfiguration().addKeyspaceSettings(setting);
}
} catch (Exception e) {
logger.warn(String.format("Skipping index creation for %s because %s", indexName, e.getMessage()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@
String languageField() default "";
String language() default "";
double score() default 1.0;

/**
* Time before expire in seconds. Superseded by {@link TimeToLive}.
*
* @return positive number when expiration should be applied.
*/
long timeToLive() default -1L;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ public interface RedisDocumentRepository<T, ID> extends KeyValueRepository<T, ID
void updateField(T entity, MetamodelField<T, ?> field, Object value);

<F> Iterable<F> getFieldsByIds(Iterable<ID> ids, MetamodelField<T, F> field);

Long getExpiration(ID id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,11 @@ public <F> Iterable<F> getFieldsByIds(Iterable<ID> ids, MetamodelField<T, F> fie
return (Iterable<F>) modulesOperations.opsForJSON().mget(Path.of("$." + field.getField().getName()), List.class, keys).stream().flatMap(List::stream).collect(Collectors.toList());
}

@Override
public Long getExpiration(ID id) {
@SuppressWarnings("unchecked")
RedisTemplate<String, String> template = (RedisTemplate<String, String>) modulesOperations.getTemplate();
return template.getExpire(metadata.getJavaType().getName() + ":" + id.toString());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ static void properties(DynamicPropertyRegistry registry) {

@SpringBootApplication
@Configuration
@EnableRedisEnhancedRepositories("com.redis.om.spring.annotations.bloom.fixtures")
@EnableRedisEnhancedRepositories(basePackages = {"com.redis.om.spring.annotations.bloom.fixtures", "com.redis.om.spring.annotations.hash.fixtures"})
static class Config {
@Autowired
Environment env;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.redis.om.spring.annotations.document;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;

import com.redis.om.spring.AbstractBaseDocumentTest;
import com.redis.om.spring.annotations.document.fixtures.ExpiringPerson;
import com.redis.om.spring.annotations.document.fixtures.ExpiringPersonDifferentTimeUnit;
import com.redis.om.spring.annotations.document.fixtures.ExpiringPersonDifferentTimeUnitRepository;
import com.redis.om.spring.annotations.document.fixtures.ExpiringPersonRepository;
import com.redis.om.spring.annotations.document.fixtures.ExpiringPersonWithDefault;
import com.redis.om.spring.annotations.document.fixtures.ExpiringPersonWithDefaultRepository;

public class DocumentTTLTests extends AbstractBaseDocumentTest {
@Autowired
ExpiringPersonWithDefaultRepository withDefaultrepository;

@Autowired
ExpiringPersonRepository withTTLAnnotationRepository;

@Autowired
ExpiringPersonDifferentTimeUnitRepository withTTLwTimeUnitAnnotationRepository;

@Autowired
RedisTemplate<String, String> template;

@BeforeEach
public void cleanUp() {
withDefaultrepository.deleteAll();
}

@Test
void testClassLevelDefaultTTL() {
ExpiringPersonWithDefault gordon = ExpiringPersonWithDefault.of("Gordon Welchman");
withDefaultrepository.save(gordon);

Long expire = withDefaultrepository.getExpiration(gordon.getId());

assertThat(expire).isEqualTo(5L);
}

@Test
void testTimeToLiveAnnotation() {
ExpiringPerson mWoodger = ExpiringPerson.of("Mike Woodger", 15L);
withTTLAnnotationRepository.save(mWoodger);

Long expire = withTTLAnnotationRepository.getExpiration(mWoodger.getId());

assertThat(expire).isEqualTo(15L);
}

@Test
void testTimeToLiveAnnotationWithDifferentTimeUnit() {
ExpiringPersonDifferentTimeUnit jWilkinson = ExpiringPersonDifferentTimeUnit.of("Jim Wilkinson", 7L);
withTTLwTimeUnitAnnotationRepository.save(jWilkinson);

Long expire = withTTLwTimeUnitAnnotationRepository.getExpiration(jWilkinson.getId());

assertThat(expire).isEqualTo(7L*24*60*60);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.redis.om.spring.annotations.document.fixtures;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.TimeToLive;

import com.redis.om.spring.annotations.Document;

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;

@Data
@NoArgsConstructor
@RequiredArgsConstructor(staticName = "of")
@Document(timeToLive = 5)
public class ExpiringPerson {
@Id String id;
@NonNull
String name;

@NonNull
@TimeToLive Long ttl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.redis.om.spring.annotations.document.fixtures;

import java.util.concurrent.TimeUnit;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.TimeToLive;

import com.redis.om.spring.annotations.Document;

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;

@Data
@NoArgsConstructor
@RequiredArgsConstructor(staticName = "of")
@Document(timeToLive = 5)
public class ExpiringPersonDifferentTimeUnit {
@Id String id;
@NonNull
String name;

@NonNull
@TimeToLive(unit = TimeUnit.DAYS) Long ttl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.redis.om.spring.annotations.document.fixtures;

import com.redis.om.spring.repository.RedisDocumentRepository;

public interface ExpiringPersonDifferentTimeUnitRepository extends RedisDocumentRepository<ExpiringPersonDifferentTimeUnit, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.redis.om.spring.annotations.document.fixtures;

import com.redis.om.spring.repository.RedisDocumentRepository;

public interface ExpiringPersonRepository extends RedisDocumentRepository<ExpiringPerson, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.redis.om.spring.annotations.document.fixtures;

import org.springframework.data.annotation.Id;

import com.redis.om.spring.annotations.Document;

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;

@Data
@NoArgsConstructor
@RequiredArgsConstructor(staticName = "of")
@Document(timeToLive = 5)
public class ExpiringPersonWithDefault {
@Id String id;
@NonNull
String name;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.redis.om.spring.annotations.document.fixtures;

import com.redis.om.spring.repository.RedisDocumentRepository;

public interface ExpiringPersonWithDefaultRepository extends RedisDocumentRepository<ExpiringPersonWithDefault, String> {
}
Loading

0 comments on commit 065ba0e

Please sign in to comment.