Add redis: Boot 4.1 + Spring Data Redis 4.1, the JDK-serialisation default vs Jackson 3 serializers, @RedisHash TTL and keyspace events, @RedisListener, and Redis as the Spring cache

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01B38FGKKam5SCGgwgduVAh3
This commit is contained in:
Claude
2026-09-21 15:46:55 +00:00
parent 8cdfcd4d8d
commit b0c39a8d4c
60 changed files with 2900 additions and 1 deletions
@@ -0,0 +1,20 @@
package com.ankurm.redis;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
/**
* Companion project for "Redis with Spring Boot 4.1" on ankurm.com.
*
* <p>Nothing here is configured on purpose: the interesting part is what Boot does
* <em>without</em> being asked. See docs/01-what-boot-gives-you.md.
*/
@SpringBootApplication
@EnableCaching
public class RedisDemoApplication {
public static void main(String[] args) {
SpringApplication.run(RedisDemoApplication.class, args);
}
}
@@ -0,0 +1,40 @@
package com.ankurm.redis.cache;
import java.util.concurrent.atomic.AtomicInteger;
import com.ankurm.redis.model.SerializableUser;
import com.ankurm.redis.model.User;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* Two cached lookups that differ only in whether the result is Serializable.
* For the annotations themselves see the cache article; this class only exists to
* put something into a Redis-backed cache.
*/
@Service
public class UserService {
private final AtomicInteger calls = new AtomicInteger();
@Cacheable("legacy")
public SerializableUser findSerializable(long id) {
calls.incrementAndGet();
return new SerializableUser(id, "Ankur");
}
@Cacheable("users")
public User find(long id) {
calls.incrementAndGet();
return new User(id, "Ankur");
}
public int calls() {
return calls.get();
}
public void resetCalls() {
calls.set(0);
}
}
@@ -0,0 +1,25 @@
package com.ankurm.redis.config;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
/**
* Switches the Redis-backed cache from JDK serialisation to JSON. Active under the
* {@code json-cache} profile so the default behaviour stays reproducible. See docs/09-redis-as-cache.md.
*/
@Configuration(proxyBeanMethods = false)
@Profile("json-cache")
public class JsonCacheConfig {
@Bean
RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.serializeValuesWith(SerializationPair.fromSerializer(JsonRedisConfig.allowListSerializer()));
}
}
@@ -0,0 +1,62 @@
package com.ankurm.redis.config;
import com.ankurm.redis.model.User;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
/**
* The "after" picture: readable keys, readable JSON values.
*
* <p>Two Jackson 3 serializers, two trade-offs. See docs/04-json-serializers.md.
* Beans are named so they sit next to Boot's own {@code redisTemplate} and {@code stringRedisTemplate}
* instead of replacing them.
*/
@Configuration(proxyBeanMethods = false)
public class JsonRedisConfig {
/**
* Writes an {@code @class} property so it can read back any type, but only types under
* {@code com.ankurm.redis.}. Whoever can write to Redis chooses the class it is read as,
* so the allow-list is not optional. See docs/04-json-serializers.md.
*/
public static GenericJacksonJsonRedisSerializer allowListSerializer() {
PolymorphicTypeValidator allowList = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.ankurm.redis.")
.build();
return GenericJacksonJsonRedisSerializer.builder()
.enableDefaultTyping(allowList)
.build();
}
/** Any value type, String keys. Values carry an {@code @class} property. */
@Bean
RedisTemplate<String, Object> jsonRedisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(StringRedisSerializer.UTF_8);
template.setHashKeySerializer(StringRedisSerializer.UTF_8);
GenericJacksonJsonRedisSerializer json = allowListSerializer();
template.setValueSerializer(json);
template.setHashValueSerializer(json);
return template;
}
/** One value type, plain JSON with no {@code @class}. Cleaner in redis-cli; one template per type. */
@Bean
RedisTemplate<String, User> userRedisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, User> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(StringRedisSerializer.UTF_8);
template.setValueSerializer(new JacksonJsonRedisSerializer<>(User.class));
return template;
}
}
@@ -0,0 +1,48 @@
package com.ankurm.redis.hash;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
/**
* A @RedisHash entity: one Redis hash per instance, at key {@code person:<id>}.
* See docs/05-redis-hash.md.
*/
@RedisHash("person")
public class Person {
@Id
private String id;
private String firstName;
/** Indexed fields get a Redis set per value, e.g. {@code person:lastName:Mhatre}. */
@Indexed
private String lastName;
private int age;
public Person() {
}
public Person(String id, String firstName, String lastName, int age) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
@Override
public String toString() {
return "Person[id=" + id + ", " + firstName + " " + lastName + ", age=" + age + "]";
}
}
@@ -0,0 +1,11 @@
package com.ankurm.redis.hash;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface PersonRepository extends CrudRepository<Person, String> {
/** Answered from the index set {@code person:lastName:<value>}, which exists because lastName is @Indexed. */
List<Person> findByLastName(String lastName);
}
@@ -0,0 +1,37 @@
package com.ankurm.redis.hash;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
/**
* A @RedisHash whose entries expire after two seconds.
* The point of this class is what happens around the expiry. See docs/06-hash-ttl-and-keyspace-events.md.
*/
@RedisHash(value = "session", timeToLive = 2)
public class Session {
@Id
private String id;
@Indexed
private String user;
public Session() {
}
public Session(String id, String user) {
this.id = id;
this.user = user;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUser() { return user; }
public void setUser(String user) { this.user = user; }
@Override
public String toString() {
return "Session[id=" + id + ", user=" + user + "]";
}
}
@@ -0,0 +1,6 @@
package com.ankurm.redis.hash;
import org.springframework.data.repository.CrudRepository;
public interface SessionRepository extends CrudRepository<Session, String> {
}
@@ -0,0 +1,39 @@
package com.ankurm.redis.messaging;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.data.redis.annotation.RedisListener;
import org.springframework.data.redis.listener.support.PubSubHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;
/**
* The annotation-driven way to receive Pub/Sub messages. Boot 4.1 auto-configures the listener
* container and turns on {@code @EnableRedisListeners}, so this is all it takes.
* See docs/07-pub-sub.md.
*/
@Component
public class NewsListener {
private final List<String> received = new CopyOnWriteArrayList<>();
/** A plain channel name: subscribes with SUBSCRIBE. The String parameter is the message body. */
@RedisListener(topic = "news")
void onNews(String body) {
received.add("@RedisListener got '" + body + "'");
}
/**
* A topic with a glob in it: subscribes with PSUBSCRIBE. The channel the message actually arrived
* on is available as a header.
*/
@RedisListener(topic = "alerts.*")
void onAlert(String body, @Header(PubSubHeaders.CHANNEL) String channel) {
received.add("@RedisListener got '" + body + "' on " + channel);
}
public List<String> received() {
return received;
}
}
@@ -0,0 +1,30 @@
package com.ankurm.redis.messaging;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
/** Remembers what it was sent, and on which channel. */
public class RecordingListener implements MessageListener {
private final String name;
private final List<String> received = new CopyOnWriteArrayList<>();
public RecordingListener(String name) {
this.name = name;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
String body = new String(message.getBody(), StandardCharsets.UTF_8);
received.add(name + " got '" + body + "' on " + channel);
}
public List<String> received() {
return received;
}
}
@@ -0,0 +1,10 @@
package com.ankurm.redis.model;
import java.io.Serializable;
/**
* The same shape as {@link User}, but Serializable, so the JDK serializer accepts it.
* What lands in Redis is a Java object stream that embeds this class's name.
*/
public record SerializableUser(long id, String name) implements Serializable {
}
@@ -0,0 +1,9 @@
package com.ankurm.redis.model;
/**
* A plain record that is deliberately NOT Serializable.
* The default RedisTemplate cannot store it; the Jackson-backed one can.
* See docs/02-default-serialization.md.
*/
public record User(long id, String name) {
}
@@ -0,0 +1,3 @@
spring.application.name=redis-demo
# Boot 4 uses spring.data.redis.* (not spring.redis.*). Defaults to localhost:6379.
# The tests start their own redis-server on 6390 and override this.
@@ -0,0 +1,5 @@
package com.ankurm.other;
/** A class that exists on the classpath but sits outside the {@code com.ankurm.redis.} allow-list. */
public record Outsider(String name) {
}
@@ -0,0 +1,115 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import com.ankurm.redis.model.SerializableUser;
import com.ankurm.redis.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.SerializationException;
/**
* What the RedisTemplate you get for free does to your data. Writes docs/output/01-default-template.txt.
*/
@RedisTest
class DefaultTemplateTest {
/** Boot registers this one: RedisTemplate<Object, Object>, bean name "redisTemplate". */
@Autowired
RedisTemplate<Object, Object> redisTemplate;
static final String LUA_GET_FIRST_KEY =
"return redis.call(\"GET\", redis.call(\"KEYS\", \"*\")[1])";
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void theDefaultTemplateWritesJavaSerialisedBytes() {
try (Transcript t = new Transcript("01-default-template.txt",
"The RedisTemplate Boot gives you: JDK serialisation for keys and values")) {
t.section("Which serializers is it using?");
t.line("key serializer: %s", redisTemplate.getKeySerializer().getClass().getSimpleName());
t.line("value serializer: %s", redisTemplate.getValueSerializer().getClass().getSimpleName());
t.section("Write a String under the key user:1");
t.line("redisTemplate.opsForValue().set(\"user:1\", \"Ankur\")");
redisTemplate.opsForValue().set("user:1", "Ankur");
t.section("What redis-cli sees");
List<String> keys = t.cli("--no-raw", "KEYS", "*");
assertThat(keys.get(0)).contains("\\xac\\xed\\x00\\x05t\\x00\\x06user:1");
t.cli("--no-raw", "GET", "user:1");
List<String> value = t.cli("--no-raw", "EVAL", LUA_GET_FIRST_KEY, "0");
assertThat(value.get(0)).contains("\\xac\\xed\\x00\\x05t\\x00\\x05Ankur");
t.section("What Java sees");
Object back = redisTemplate.opsForValue().get("user:1");
t.line("redisTemplate.opsForValue().get(\"user:1\") = %s", back);
assertThat(back).isEqualTo("Ankur");
}
}
@Test
void aValueThatIsNotSerializableIsRejected() {
try (Transcript t = new Transcript("02-default-template-not-serializable.txt",
"The default template refuses a value that is not java.io.Serializable")) {
t.section("A record that does not implement Serializable");
t.line("redisTemplate.opsForValue().set(\"user:2\", new User(2, \"Ankur\"))");
Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(
() -> redisTemplate.opsForValue().set("user:2", new User(2, "Ankur")));
assertThat(thrown).isInstanceOf(SerializationException.class);
t.line("%s", thrown.getClass().getName());
t.line(" message: %s", thrown.getMessage());
t.line(" cause: %s", thrown.getCause().getMessage());
t.line(" root: %s", NestedExceptionUtils.getMostSpecificCause(thrown).getMessage());
t.line("keys afterwards: %d", redisTemplate.keys("*").size());
assertThat(redisTemplate.keys("*")).isEmpty();
t.section("The same shape, implementing Serializable");
t.line("redisTemplate.opsForValue().set(\"user:3\", new SerializableUser(3, \"Ankur\"))");
redisTemplate.opsForValue().set("user:3", new SerializableUser(3, "Ankur"));
List<String> value = t.cli("--no-raw", "EVAL", LUA_GET_FIRST_KEY, "0");
assertThat(value.get(0)).contains("com.ankurm.redis.model.SerializableUser");
t.line("read back: %s", redisTemplate.opsForValue().get("user:3"));
}
}
@Test
void incrementSucceedsAndTheNextReadFails() {
try (Transcript t = new Transcript("03-default-template-increment.txt",
"INCR works with the default template, but the template cannot read the counter it made")) {
t.section("Increment a counter");
Long after = redisTemplate.opsForValue().increment("hits");
t.line("redisTemplate.opsForValue().increment(\"hits\") = %d", after);
assertThat(after).isEqualTo(1L);
t.section("What redis-cli sees");
t.cli("--no-raw", "KEYS", "*");
t.cli("--no-raw", "EVAL", LUA_GET_FIRST_KEY, "0");
t.section("Read it back through the same template");
t.line("redisTemplate.opsForValue().get(\"hits\")");
Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(
() -> redisTemplate.opsForValue().get("hits"));
assertThat(thrown).isInstanceOf(SerializationException.class);
t.line("%s", thrown.getClass().getName());
t.line(" message: %s", thrown.getMessage());
Throwable root = NestedExceptionUtils.getMostSpecificCause(thrown);
t.line(" root: %s: %s", root.getClass().getName(), root.getMessage());
assertThatThrownBy(() -> redisTemplate.opsForValue().get("hits")).isInstanceOf(SerializationException.class);
}
}
}
@@ -0,0 +1,71 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import java.util.List;
import com.ankurm.redis.hash.Session;
import com.ankurm.redis.hash.SessionRepository;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
/**
* @RedisHash(timeToLive) with Boot's defaults: keyspace events are OFF, so Redis expires the hash
* and Spring Data Redis never hears about it. Writes docs/output/09-hash-ttl-events-off.txt.
*/
@RedisTest
class HashTtlEventsOffTest {
@Autowired
SessionRepository sessions;
@BeforeAll
static void serverDefaults() {
LocalRedis.reset();
}
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void theHashExpiresButTheIndexEntriesStay() {
try (Transcript t = new Transcript("09-hash-ttl-events-off.txt",
"@RedisHash(timeToLive = 2) with Boot's defaults (keyspace events OFF)")) {
t.section("Server setting");
t.cli("--no-raw", "CONFIG", "GET", "notify-keyspace-events");
t.section("Save a session that lives for two seconds");
sessions.save(new Session("s1", "ankur"));
t.line("sessions.save(new Session(\"s1\", \"ankur\"))");
t.cliSorted("KEYS", "*");
List<String> ttl = t.cli("--no-raw", "TTL", "session:s1");
assertThat(ttl.get(0)).isEqualTo("(integer) 2");
t.section("Wait for Redis to expire it");
await().atMost(Duration.ofSeconds(10)).until(() -> stringCount("session:s1") == 0);
t.cli("--no-raw", "EXISTS", "session:s1");
t.section("What is left behind");
t.cliSorted("KEYS", "*");
t.cliSorted("SMEMBERS", "session");
t.cliSorted("SMEMBERS", "session:user:ankur");
t.section("What the repository says");
t.line("sessions.findById(\"s1\") = %s", sessions.findById("s1"));
t.line("sessions.count() = %d", sessions.count());
assertThat(sessions.findById("s1")).isEmpty();
}
}
private long stringCount(String key) {
return Long.parseLong(Transcript.run(java.util.List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "EXISTS", key)).get(0));
}
}
@@ -0,0 +1,111 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.ankurm.redis.hash.Session;
import com.ankurm.redis.hash.SessionRepository;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.data.redis.core.RedisKeyExpiredEvent;
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
/**
* The same entity with {@code enableKeyspaceEvents = ON_STARTUP}. Spring Data Redis turns the
* server setting on, keeps a phantom copy of the hash for five more minutes, and cleans up the
* index entries when Redis announces the expiry. Writes docs/output/10-hash-ttl-events-on.txt.
*/
@RedisTest
class HashTtlEventsOnTest {
static List<String> before;
@Autowired
SessionRepository sessions;
@Autowired
ExpiryLog expiryLog;
@TestConfiguration
@EnableRedisRepositories(basePackageClasses = SessionRepository.class, enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)
static class Config {
@Bean
ExpiryLog expiryLog() {
return new ExpiryLog();
}
}
static class ExpiryLog {
final List<String> events = new CopyOnWriteArrayList<>();
@EventListener
void on(RedisKeyExpiredEvent<?> event) {
events.add("keyspace=" + event.getKeyspace() + " id=" + new String(event.getId()) + " value=" + event.getValue());
}
}
@BeforeAll
static void serverDefaults() {
// runs before the Spring context is created, so this is the setting Spring Data Redis finds
LocalRedis.reset();
before = Transcript.run(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "--no-raw", "CONFIG", "GET", "notify-keyspace-events"));
}
@AfterAll
static void restoreServerDefaults() {
LocalRedis.reset();
}
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void springCleansUpWhenRedisAnnouncesTheExpiry() {
try (Transcript t = new Transcript("10-hash-ttl-events-on.txt",
"@RedisHash(timeToLive = 2) with enableKeyspaceEvents = ON_STARTUP")) {
t.section("Server setting before the application context started");
t.line("$ redis-cli -p %d --no-raw CONFIG GET notify-keyspace-events", LocalRedis.PORT);
before.forEach(l -> t.line("%s", l));
t.section("Server setting now");
t.cli("--no-raw", "CONFIG", "GET", "notify-keyspace-events");
t.section("Save a session that lives for two seconds");
sessions.save(new Session("s1", "ankur"));
t.line("sessions.save(new Session(\"s1\", \"ankur\"))");
t.cliSorted("KEYS", "*");
List<String> ttl = t.cli("--no-raw", "TTL", "session:s1");
assertThat(ttl.get(0)).isEqualTo("(integer) 2");
List<String> phantom = t.cli("--no-raw", "TTL", "session:s1:phantom");
assertThat(phantom.get(0)).isEqualTo("(integer) 302");
t.section("Wait for Redis to expire it, and for Spring to react");
await().atMost(Duration.ofSeconds(10)).until(() -> !expiryLog.events.isEmpty());
await().atMost(Duration.ofSeconds(5)).until(() ->
Transcript.run(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "DBSIZE")).get(0).equals("0"));
expiryLog.events.forEach(e -> t.line("RedisKeyExpiredEvent: %s", e));
t.section("What is left behind");
t.cli("--no-raw", "DBSIZE");
t.line("sessions.count() = %d", sessions.count());
assertThat(sessions.count()).isZero();
}
}
}
@@ -0,0 +1,109 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import com.ankurm.redis.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* The "after" picture: String keys and Jackson 3 values. Writes docs/output/06-jackson-serializers.txt
* and 07-jackson-untrusted-class.txt.
*/
@RedisTest
class JacksonSerializerTest {
@Autowired
@Qualifier("userRedisTemplate")
RedisTemplate<String, User> userTemplate;
@Autowired
@Qualifier("jsonRedisTemplate")
RedisTemplate<String, Object> jsonTemplate;
@Autowired
RedisConnectionFactory factory;
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void typedAndGenericSerializers() {
try (Transcript t = new Transcript("06-jackson-serializers.txt",
"Jackson 3 serializers: JacksonJsonRedisSerializer and GenericJacksonJsonRedisSerializer")) {
t.section("JacksonJsonRedisSerializer<User>: one type, plain JSON");
t.line("value serializer: %s", userTemplate.getValueSerializer().getClass().getName());
userTemplate.opsForValue().set("user:1", new User(1, "Ankur"));
t.line("userTemplate.opsForValue().set(\"user:1\", new User(1, \"Ankur\"))");
t.cli("KEYS", "*");
t.cli("GET", "user:1");
User typed = userTemplate.opsForValue().get("user:1");
t.line("read back: %s (%s)", typed, typed.getClass().getSimpleName());
assertThat(typed).isEqualTo(new User(1, "Ankur"));
t.section("GenericJacksonJsonRedisSerializer with an allow-list: any type, plus @class");
t.line("value serializer: %s", jsonTemplate.getValueSerializer().getClass().getName());
jsonTemplate.opsForValue().set("user:2", new User(2, "Ankur"));
t.line("jsonTemplate.opsForValue().set(\"user:2\", new User(2, \"Ankur\"))");
t.cli("GET", "user:2");
Object generic = jsonTemplate.opsForValue().get("user:2");
t.line("read back: %s (%s)", generic, generic.getClass().getSimpleName());
assertThat(generic).isEqualTo(new User(2, "Ankur"));
t.section("GenericJacksonJsonRedisSerializer with no typing configured");
RedisTemplate<String, Object> plain = new RedisTemplate<>();
plain.setConnectionFactory(factory);
plain.setKeySerializer(StringRedisSerializer.UTF_8);
plain.setValueSerializer(GenericJacksonJsonRedisSerializer.builder().build());
plain.afterPropertiesSet();
plain.opsForValue().set("user:3", new User(3, "Ankur"));
t.line("plain.opsForValue().set(\"user:3\", new User(3, \"Ankur\"))");
t.cli("GET", "user:3");
Object untyped = plain.opsForValue().get("user:3");
t.line("read back: %s (%s)", untyped, untyped.getClass().getSimpleName());
t.section("Two different shapes of the same JSON");
t.cli("GET", "user:1");
t.cli("GET", "user:2");
}
}
@Test
void whoeverWritesToRedisChoosesTheClass() {
try (Transcript t = new Transcript("07-jackson-untrusted-class.txt",
"The @class property is input: the allow-list decides what it may name")) {
t.section("A class outside the allow-list");
t.cli("SET", "user:evil", "{\"@class\":\"com.ankurm.other.Outsider\",\"name\":\"x\"}");
t.line("jsonTemplate.opsForValue().get(\"user:evil\")");
Throwable denied = catchThrowable(() -> jsonTemplate.opsForValue().get("user:evil"));
assertThat(denied).isInstanceOf(SerializationException.class);
t.line("%s", denied.getClass().getName());
t.line(" message: %s", firstLine(denied.getMessage()));
t.section("A class inside the allow-list that no longer exists");
t.cli("SET", "user:gone", "{\"@class\":\"com.ankurm.redis.model.Gone\",\"id\":9}");
t.line("jsonTemplate.opsForValue().get(\"user:gone\")");
Throwable gone = catchThrowable(() -> jsonTemplate.opsForValue().get("user:gone"));
assertThat(gone).isInstanceOf(SerializationException.class);
t.line("%s", gone.getClass().getName());
t.line(" message: %s", firstLine(gone.getMessage()));
}
}
private static String firstLine(String message) {
return message == null ? "null" : message.lines().findFirst().orElse("");
}
}
@@ -0,0 +1,70 @@
package com.ankurm.redis;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.List;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
/**
* Starts a throwaway {@code redis-server} on port 6390 for the whole test JVM and stops it at the end.
*
* <p>It refuses to reuse a server that is already listening, because the tests call FLUSHALL. If the
* port is busy, stop whatever owns it. Needs {@code redis-server} and {@code redis-cli} on the PATH
* (or run one in Docker and change nothing: publish it on 6390 and the extension will complain, which
* is the point).
*/
public class LocalRedis implements BeforeAllCallback {
public static final int PORT = 6390;
private static Process server;
@Override
public synchronized void beforeAll(ExtensionContext context) throws Exception {
if (server != null) {
return;
}
if (listening()) {
throw new IllegalStateException("port " + PORT + " is already in use; the tests FLUSHALL, so they start their own server");
}
server = new ProcessBuilder("redis-server", "--port", String.valueOf(PORT),
"--save", "", "--appendonly", "no", "--loglevel", "warning")
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
Transcript.run(List.of("redis-cli", "-p", String.valueOf(PORT), "shutdown", "nosave"));
} finally {
server.destroy();
}
}));
for (int i = 0; i < 100 && !listening(); i++) {
Thread.sleep(50);
}
if (!listening()) {
throw new IllegalStateException("redis-server did not start on " + PORT);
}
}
private static boolean listening() {
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress("127.0.0.1", PORT), 200);
return true;
} catch (IOException e) {
return false;
}
}
/** Puts server settings a test might change back to their defaults. */
public static void reset() {
Transcript.run(List.of("redis-cli", "-p", String.valueOf(PORT), "config", "set", "notify-keyspace-events", ""));
}
/** Wipes the test server so every transcript starts from an empty keyspace. */
public static void flush() {
Transcript.run(List.of("redis-cli", "-p", String.valueOf(PORT), "flushall"));
}
}
@@ -0,0 +1,128 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import com.ankurm.redis.messaging.NewsListener;
import com.ankurm.redis.messaging.RecordingListener;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
/**
* Pub/Sub with Boot 4.1's auto-configured listener container. Writes docs/output/11-pub-sub.txt.
*/
@RedisTest
class PubSubTest {
@Autowired
ApplicationContext context;
@Autowired
RedisMessageListenerContainer container;
@Autowired
StringRedisTemplate template;
@Autowired
NewsListener news;
@BeforeEach
void clean() {
LocalRedis.flush();
news.received().clear();
}
@Test
void publishAndSubscribe() {
try (Transcript t = new Transcript("11-pub-sub.txt", "Pub/Sub: what is delivered, to whom, and what is lost")) {
t.section("The container Boot created");
t.line("beans of type RedisMessageListenerContainer: %s",
Arrays.toString(context.getBeanNamesForType(RedisMessageListenerContainer.class)));
t.line("running=%s listening=%s", container.isRunning(), container.isListening());
awaitSubscribers("news", 1);
t.section("Publishing to a channel nobody listens to");
Long none = template.convertAndSend("quiet", "hello?");
t.line("template.convertAndSend(\"quiet\", \"hello?\") = %d receivers", none);
assertThat(none).isZero();
t.cli("--no-raw", "PUBSUB", "NUMSUB", "quiet");
t.section("Publishing to news, which has an @RedisListener");
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
Long one = template.convertAndSend("news", "first");
t.line("template.convertAndSend(\"news\", \"first\") = %d receiver", one);
assertThat(one).isEqualTo(1L);
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 1);
news.received().forEach(t::line);
t.section("A second listener that subscribes late");
RecordingListener late = new RecordingListener("late listener");
container.addMessageListener(late, new ChannelTopic("news"));
t.line("container.addMessageListener(late, new ChannelTopic(\"news\"))");
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
Long two = template.convertAndSend("news", "second");
t.line("template.convertAndSend(\"news\", \"second\") = %d receiver", two);
assertThat(two).isEqualTo(1L);
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 2 && late.received().size() == 1);
news.received().forEach(t::line);
late.received().forEach(t::line);
container.removeMessageListener(late);
t.section("A pattern subscription (topic = \"alerts.*\")");
t.cli("--no-raw", "PUBSUB", "NUMPAT");
t.cli("--no-raw", "PUBSUB", "NUMSUB", "alerts.disk");
Long alert = template.convertAndSend("alerts.disk", "90% full");
t.line("template.convertAndSend(\"alerts.disk\", \"90%% full\") = %d receiver", alert);
assertThat(alert).isEqualTo(1L);
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 3);
t.line("%s", news.received().get(2));
}
}
@Test
void aMessageIsNotKeptForAListenerThatIsNotThere() {
try (Transcript t = new Transcript("12-pub-sub-lost-message.txt", "Pub/Sub is at-most-once: a stopped listener loses messages")) {
awaitSubscribers("news", 1);
t.section("Stop the listener container, then publish");
container.stop();
t.line("container.stop()");
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
Long lost = template.convertAndSend("news", "while you were away");
t.line("template.convertAndSend(\"news\", \"while you were away\") = %d receivers", lost);
assertThat(lost).isZero();
t.section("Start it again");
container.start();
t.line("container.start()");
awaitSubscribers("news", 1);
t.cli("--no-raw", "PUBSUB", "NUMSUB", "news");
t.line("messages the listener has: %s", news.received());
assertThat(news.received()).isEmpty();
t.section("Once it is back, messages flow again");
Long back = template.convertAndSend("news", "welcome back");
t.line("template.convertAndSend(\"news\", \"welcome back\") = %d receiver", back);
await().atMost(Duration.ofSeconds(5)).until(() -> news.received().size() == 1);
news.received().forEach(t::line);
}
}
private void awaitSubscribers(String channel, int expected) {
await().atMost(Duration.ofSeconds(10)).until(() -> {
List<String> out = Transcript.run(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT), "PUBSUB", "NUMSUB", channel));
return out.size() == 2 && out.get(1).equals(String.valueOf(expected));
});
}
}
@@ -0,0 +1,66 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import java.util.List;
import com.ankurm.redis.cache.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.test.context.TestPropertySource;
/**
* Redis as the Spring cache, with Boot's defaults. Writes docs/output/14-cache-default.txt.
*/
@RedisTest
@TestPropertySource(properties = "spring.cache.redis.time-to-live=10m")
class RedisCacheDefaultTest {
@Autowired
CacheManager cacheManager;
@Autowired
UserService users;
@BeforeEach
void clean() {
LocalRedis.flush();
users.resetCalls();
}
@Test
void defaultsUseJdkSerialisationForValues() {
try (Transcript t = new Transcript("14-cache-default.txt",
"Redis as the Spring cache: what the defaults store")) {
t.section("Which cache manager did Boot pick?");
t.line("%s", cacheManager.getClass().getName());
t.section("A Serializable result, cached");
users.findSerializable(1);
users.findSerializable(1);
t.line("findSerializable(1) called twice, method body ran %d time(s)", users.calls());
assertThat(users.calls()).isEqualTo(1);
t.cliSorted("KEYS", "*");
List<String> ttl = t.cli("--no-raw", "TTL", "legacy::1");
assertThat(ttl.get(0)).isEqualTo("(integer) 600");
t.cli("--no-raw", "GET", "legacy::1");
t.section("A result that is not Serializable");
t.line("users.find(1)");
Throwable thrown = catchThrowable(() -> users.find(1));
assertThat(thrown).isNotNull();
t.line("%s", thrown.getClass().getName());
t.line(" message: %s", thrown.getMessage());
Throwable root = NestedExceptionUtils.getMostSpecificCause(thrown);
t.line(" root: %s", root.getMessage());
t.line("method body ran %d time(s) in total", users.calls());
t.cliSorted("KEYS", "*");
}
}
}
@@ -0,0 +1,58 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import com.ankurm.redis.cache.UserService;
import com.ankurm.redis.model.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
/**
* The same cache with a RedisCacheConfiguration bean that writes JSON. Writes docs/output/15-cache-json.txt.
*/
@RedisTest
@ActiveProfiles("json-cache")
@TestPropertySource(properties = "spring.cache.redis.time-to-live=10m")
class RedisCacheJsonTest {
@Autowired
CacheManager cacheManager;
@Autowired
UserService users;
@BeforeEach
void clean() {
LocalRedis.flush();
users.resetCalls();
}
@Test
void jsonValuesAreReadableAndTypeless() {
try (Transcript t = new Transcript("15-cache-json.txt",
"Redis as the Spring cache with a Jackson 3 value serializer")) {
t.section("The result that was not Serializable now caches");
User first = users.find(1);
User second = users.find(1);
t.line("find(1) called twice, method body ran %d time(s)", users.calls());
t.line("second call returned: %s", second);
assertThat(users.calls()).isEqualTo(1);
assertThat(second).isEqualTo(first);
t.section("What is in Redis");
t.cliSorted("KEYS", "*");
t.cli("GET", "users::1");
List<String> ttl = t.cli("--no-raw", "TTL", "users::1");
t.line("spring.cache.redis.time-to-live=10m is set, and the bean says 5 minutes");
assertThat(ttl.get(0)).isEqualTo("(integer) 300");
}
}
}
@@ -0,0 +1,71 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import com.ankurm.redis.hash.Person;
import com.ankurm.redis.hash.PersonRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
/**
* What a @RedisHash repository actually writes. Writes docs/output/08-redis-hash.txt.
*/
@RedisTest
class RedisHashTest {
@Autowired
PersonRepository people;
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void repositoryWritesAHashASetAndIndexSets() {
try (Transcript t = new Transcript("08-redis-hash.txt",
"@RedisHash and CrudRepository: the keys Spring Data Redis creates")) {
t.section("Save two people");
people.save(new Person("1", "Ankur", "Mhatre", 40));
people.save(new Person("2", "Asha", "Mhatre", 38));
t.line("people.save(new Person(\"1\", \"Ankur\", \"Mhatre\", 40))");
t.line("people.save(new Person(\"2\", \"Asha\", \"Mhatre\", 38))");
t.section("Every key it created");
List<String> keys = t.cliSorted("KEYS", "*");
assertThat(keys).containsExactly("person", "person:1", "person:1:idx", "person:2", "person:2:idx", "person:lastName:Mhatre");
t.section("The entity is a hash");
t.cli("--no-raw", "TYPE", "person:1");
t.cli("--no-raw", "HGETALL", "person:1");
t.section("The keyspace is a set of ids");
t.cliSorted("SMEMBERS", "person");
t.section("The @Indexed field is a set per value");
t.cliSorted("SMEMBERS", "person:lastName:Mhatre");
t.section("And each entity remembers which index sets it is in");
t.cliSorted("SMEMBERS", "person:1:idx");
t.section("What the repository returns");
t.line("people.findById(\"1\") = %s", people.findById("1").orElseThrow());
t.line("people.count() = %d", people.count());
List<Person> found = people.findByLastName("Mhatre");
t.line("people.findByLastName(\"Mhatre\") = %d results", found.size());
assertThat(found).hasSize(2);
t.section("Delete one, and look again");
people.deleteById("1");
t.line("people.deleteById(\"1\")");
t.cliSorted("KEYS", "*");
t.cliSorted("SMEMBERS", "person:lastName:Mhatre");
t.cliSorted("SMEMBERS", "person");
}
}
}
@@ -0,0 +1,17 @@
package com.ankurm.redis;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
/** A Spring Boot test wired to the throwaway redis-server that {@link LocalRedis} starts. */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(LocalRedis.class)
@SpringBootTest(properties = {"spring.data.redis.port=" + LocalRedis.PORT, "spring.main.banner-mode=off"})
public @interface RedisTest {
}
@@ -0,0 +1,95 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
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 org.springframework.data.redis.core.StringRedisTemplate;
/**
* StringRedisTemplate, and why two templates over one Redis do not share keys.
* Writes docs/output/04-string-template.txt and 05-two-templates-two-keyspaces.txt.
*/
@RedisTest
class StringTemplateTest {
@Autowired
StringRedisTemplate stringTemplate;
@Autowired
RedisTemplate<Object, Object> redisTemplate;
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void stringTemplateWritesWhatRedisCliCanRead() {
try (Transcript t = new Transcript("04-string-template.txt",
"StringRedisTemplate: every key and value is UTF-8 text")) {
t.section("Which serializers is it using?");
t.line("key serializer: %s", stringTemplate.getKeySerializer().getClass().getSimpleName());
t.line("value serializer: %s", stringTemplate.getValueSerializer().getClass().getSimpleName());
t.line("hash key serializer: %s", stringTemplate.getHashKeySerializer().getClass().getSimpleName());
t.line("hash value serializer: %s", stringTemplate.getHashValueSerializer().getClass().getSimpleName());
t.section("A string value");
stringTemplate.opsForValue().set("user:1", "Ankur");
t.line("stringTemplate.opsForValue().set(\"user:1\", \"Ankur\")");
t.cli("--no-raw", "GET", "user:1");
t.section("A counter");
stringTemplate.opsForValue().increment("hits");
stringTemplate.opsForValue().increment("hits");
t.line("stringTemplate.opsForValue().increment(\"hits\") twice = %s", stringTemplate.opsForValue().get("hits"));
t.cli("--no-raw", "GET", "hits");
assertThat(stringTemplate.opsForValue().get("hits")).isEqualTo("2");
t.section("A hash");
stringTemplate.opsForHash().put("user:1:profile", "city", "Pune");
stringTemplate.opsForHash().put("user:1:profile", "editor", "vim");
t.line("stringTemplate.opsForHash().put(\"user:1:profile\", \"city\", \"Pune\") and (\"editor\", \"vim\")");
t.cli("--no-raw", "HGETALL", "user:1:profile");
t.section("A list, a set and a sorted set");
stringTemplate.opsForList().rightPushAll("queue", "a", "b", "c");
stringTemplate.opsForSet().add("tags", "java", "spring");
stringTemplate.opsForZSet().add("scores", "ankur", 42);
t.cli("--no-raw", "LRANGE", "queue", "0", "-1");
t.cliSorted("SMEMBERS", "tags");
t.cli("--no-raw", "ZRANGE", "scores", "0", "-1", "WITHSCORES");
t.cliSorted("KEYS", "*");
}
}
@Test
void twoTemplatesOverOneRedisDoNotSeeEachOthersKeys() {
try (Transcript t = new Transcript("05-two-templates-two-keyspaces.txt",
"Same key name, two templates, two different keys")) {
t.section("Write user:1 with StringRedisTemplate");
stringTemplate.opsForValue().set("user:1", "from-string-template");
t.line("stringTemplate.opsForValue().get(\"user:1\") = %s", stringTemplate.opsForValue().get("user:1"));
Object viaDefault = redisTemplate.opsForValue().get("user:1");
t.line("redisTemplate.opsForValue().get(\"user:1\") = %s", viaDefault);
assertThat(viaDefault).isNull();
t.section("Write user:1 with the default RedisTemplate");
redisTemplate.opsForValue().set("user:1", "from-default-template");
t.line("stringTemplate.opsForValue().get(\"user:1\") = %s", stringTemplate.opsForValue().get("user:1"));
t.line("redisTemplate.opsForValue().get(\"user:1\") = %s", redisTemplate.opsForValue().get("user:1"));
assertThat(stringTemplate.opsForValue().get("user:1")).isEqualTo("from-string-template");
t.section("What Redis holds");
List<String> size = t.cli("--no-raw", "DBSIZE");
assertThat(size.get(0)).isEqualTo("(integer) 2");
t.cli("--no-raw", "GET", "user:1");
}
}
}
@@ -0,0 +1,112 @@
package com.ankurm.redis;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Writes a numbered transcript under {@code docs/output/} and echoes it to the console.
* Every console block quoted in the article comes out of one of these files verbatim.
*
* <p>{@link #cli} runs the real {@code redis-cli} against the test server and records both the
* command and what it printed, so the article's "what redis-cli shows" blocks are not typed by hand.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("docs", "output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
public Transcript section(String heading) {
out.println();
out.println("--- " + heading + " ---");
return this;
}
/** Runs {@code redis-cli -p <port> <args>} and records the command and its output. Returns the output lines. */
public List<String> cli(String... args) {
return cli(false, args);
}
/** Like {@link #cli} but sorts the output lines, printing {@code | sort} after the command (KEYS order is arbitrary). */
public List<String> cliSorted(String... args) {
return cli(true, args);
}
private List<String> cli(boolean sort, String... args) {
List<String> command = new ArrayList<>(List.of("redis-cli", "-p", String.valueOf(LocalRedis.PORT)));
command.addAll(List.of(args));
out.println("$ " + shown(command) + (sort ? " | sort" : ""));
List<String> lines = run(command);
if (sort) {
Collections.sort(lines);
}
lines.forEach(out::println);
return lines;
}
static List<String> run(List<String> command) {
try {
Process p = new ProcessBuilder(command).redirectErrorStream(true).start();
String text = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (!p.waitFor(10, TimeUnit.SECONDS)) {
p.destroyForcibly();
throw new IllegalStateException("timed out: " + command);
}
List<String> lines = new ArrayList<>(List.of(text.split("\n", -1)));
while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {
lines.remove(lines.size() - 1);
}
return lines;
} catch (IOException | InterruptedException e) {
throw new IllegalStateException("could not run " + command, e);
}
}
private static String shown(List<String> command) {
StringBuilder sb = new StringBuilder();
for (String s : command) {
if (sb.length() > 0) {
sb.append(' ');
}
boolean quote = s.isEmpty() || s.chars().anyMatch(c -> " *'\"{}[]$;|".indexOf(c) >= 0);
sb.append(quote ? "'" + s.replace("'", "'\\''") + "'" : s);
}
return sb.toString();
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}
@@ -0,0 +1,97 @@
package com.ankurm.redis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeUnit;
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.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
/**
* Time-to-live from the template's side, checked with redis-cli from the server's. Writes docs/output/13-ttl.txt.
*/
@RedisTest
class TtlTest {
@Autowired
StringRedisTemplate template;
@BeforeEach
void clean() {
LocalRedis.flush();
}
@Test
void ttlSemantics() {
try (Transcript t = new Transcript("13-ttl.txt", "TTL: set, read, lose, keep and clear it")) {
t.section("No TTL, and a key that does not exist");
template.opsForValue().set("plain", "v");
t.line("set(\"plain\", \"v\")");
t.line("getExpire(\"plain\") = %d", template.getExpire("plain"));
t.line("getExpire(\"missing\") = %d", template.getExpire("missing"));
assertThat(template.getExpire("plain")).isEqualTo(-1L);
assertThat(template.getExpire("missing")).isEqualTo(-2L);
t.section("Set a value with a Duration");
template.opsForValue().set("session", "v1", Duration.ofMinutes(10));
t.line("set(\"session\", \"v1\", Duration.ofMinutes(10))");
t.line("getExpire(\"session\") = %d", template.getExpire("session"));
t.line("getExpire(\"session\", TimeUnit.MINUTES) = %d", template.getExpire("session", TimeUnit.MINUTES));
List<String> ttl = t.cli("--no-raw", "TTL", "session");
assertThat(ttl.get(0)).isEqualTo("(integer) 600");
t.section("A plain set() afterwards throws the TTL away");
template.opsForValue().set("session", "v2");
t.line("set(\"session\", \"v2\")");
t.line("getExpire(\"session\") = %d", template.getExpire("session"));
t.cli("--no-raw", "TTL", "session");
assertThat(template.getExpire("session")).isEqualTo(-1L);
t.section("Put a TTL back, then overwrite while keeping it");
template.expire("session", Duration.ofMinutes(10));
t.line("expire(\"session\", Duration.ofMinutes(10))");
template.opsForValue().set("session", "v3", Expiration.keepTtl());
t.line("set(\"session\", \"v3\", Expiration.keepTtl())");
t.line("getExpire(\"session\") = %d, value = %s", template.getExpire("session"), template.opsForValue().get("session"));
t.cli("--no-raw", "TTL", "session");
assertThat(template.getExpire("session")).isEqualTo(600L);
t.section("Sub-second precision");
template.opsForValue().set("short", "v", Duration.ofMillis(1500));
t.line("set(\"short\", \"v\", Duration.ofMillis(1500))");
t.line("getExpire(\"short\", TimeUnit.MILLISECONDS) <= 1500: %s", template.getExpire("short", TimeUnit.MILLISECONDS) <= 1500);
t.line("getExpire(\"short\") in seconds = %d", template.getExpire("short"));
t.section("persist() removes a TTL");
template.persist("session");
t.line("persist(\"session\")");
t.line("getExpire(\"session\") = %d", template.getExpire("session"));
assertThat(template.getExpire("session")).isEqualTo(-1L);
t.section("Expiry actually happens");
template.opsForValue().set("blink", "v", Duration.ofMillis(300));
t.line("set(\"blink\", \"v\", Duration.ofMillis(300))");
t.line("get(\"blink\") straight away = %s", template.opsForValue().get("blink"));
await().atMost(Duration.ofSeconds(5)).until(() -> template.opsForValue().get("blink") == null);
t.line("get(\"blink\") later = %s", template.opsForValue().get("blink"));
t.cli("--no-raw", "EXISTS", "blink");
t.section("setIfAbsent with a TTL is the smallest lock");
Boolean first = template.opsForValue().setIfAbsent("lock:job", "worker-1", Duration.ofSeconds(30));
Boolean second = template.opsForValue().setIfAbsent("lock:job", "worker-2", Duration.ofSeconds(30));
t.line("setIfAbsent(\"lock:job\", \"worker-1\", 30s) = %s", first);
t.line("setIfAbsent(\"lock:job\", \"worker-2\", 30s) = %s", second);
t.cli("--no-raw", "GET", "lock:job");
t.cli("--no-raw", "TTL", "lock:job");
assertThat(first).isTrue();
assertThat(second).isFalse();
}
}
}