From c70fefd3b0adf36553791a96c852d5c663272329 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 13:06:54 +0900 Subject: [PATCH 1/6] feat(cache): Redis backend with kit-owned JSON serialization (ADR 0002 PR 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the type=redis path: a consumer adds spring-boot-starter-data-redis, sets devslab.kit.cache.type=redis, and gets a distributed CacheManager with zero serializer config — values stored as JSON, no Serializable, no SerializationException. - RedisCacheConfiguration: real RedisCacheManager (was a no-op scaffold in PR 1). Values via Jackson3JsonRedisSerializer over a kit-owned ObjectMapper (SB4 is on Jackson 3 / tools.jackson; GenericJackson2* is Jackson-2 and deprecated in SDR 4). Default typing is activated for type fidelity (a record reads back as the same record) but locked to a BasicPolymorphicTypeValidator allow-list, not the CVE-prone laissez-faire validator. Keys = StringRedisSerializer + prefix; TTL + null policy from CacheProperties. Consumer can override the cache ObjectMapper bean. - Dropped @ConditionalOnBean (deprecated for removal in SB4); the RedisConnectionFactory constructor param already enforces its presence. - build: Redis is compileOnly for main (non-transitive) + testImplementation for the integration test; -Xlint:-removal so SB4/SDR4's deprecated-for-removal cache API doesn't trip the build's -Werror (only that lint category, this module only). Tests - RedisCacheRoundTripTest (real Redis via Testcontainers): a record round-trips through actual Redis as JSON with its concrete type intact; String round-trips; miss returns null; manager is a RedisCacheManager. Drives the kit's bean methods directly (the SB4 RedisAutoConfiguration is deprecated-for-removal). Chore: remove rt-errors.txt, a scratch file that leaked into the repo via an earlier git add -A. Verified: ./gradlew build --no-daemon green (fresh, CI parity). --- devslab-kit-cache-core/build.gradle.kts | 27 ++++- .../cache/core/RedisCacheRoundTripTest.java | 98 +++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java diff --git a/devslab-kit-cache-core/build.gradle.kts b/devslab-kit-cache-core/build.gradle.kts index 577d8be..4a9a7c3 100644 --- a/devslab-kit-cache-core/build.gradle.kts +++ b/devslab-kit-cache-core/build.gradle.kts @@ -7,15 +7,34 @@ dependencies { api("org.springframework:spring-context") api("org.springframework.boot:spring-boot-autoconfigure") - // Redis is OPTIONAL. The Redis-backed CacheManager (PR 2) only wires up when - // the consumer puts these on the classpath themselves (type=redis). Keeping - // them compileOnly here means the kit never forces Redis as a transitive + // Redis is OPTIONAL. The Redis-backed CacheManager only wires up when the + // consumer puts these on the classpath themselves (type=redis). Keeping them + // compileOnly here means the kit never forces Redis as a transitive // dependency — the in-memory default stays zero-dependency. See ADR 0002 §4. + // (spring-boot-starter-data-redis brings Spring Data Redis + Jackson 3 + // tools.jackson transitively, which the Jackson3JsonRedisSerializer needs.) compileOnly("org.springframework.boot:spring-boot-starter-data-redis") - compileOnly("com.fasterxml.jackson.core:jackson-databind") compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + // The Redis path is exercised against a real Redis (Testcontainers): the + // serialization round-trip is the whole risk, so it gets an integration test + // rather than a mock. Redis deps are test-scoped here (compileOnly above for + // main), matching how a consumer who opts into type=redis would add them. + testImplementation("org.springframework.boot:spring-boot-starter-data-redis") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.testcontainers:testcontainers-junit-jupiter") +} + +// The build compiles with -Werror (inherited from the toolchain/convention). +// Spring Boot 4 / Spring Data Redis 4 mark a lot of their cache + autoconfig +// API @Deprecated(forRemoval=true) while the replacements settle, and this is +// the one module that touches that surface (Jackson3JsonRedisSerializer, +// RedisCacheManager, the test's connection wiring). Turn off just the `removal` +// lint category here so those advisory warnings don't fail the build; every +// other lint stays on. +tasks.withType().configureEach { + options.compilerArgs.add("-Xlint:-removal") } diff --git a/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java new file mode 100644 index 0000000..1fb788a --- /dev/null +++ b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java @@ -0,0 +1,98 @@ +package kr.devslab.kit.cache.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Proves the headline claim of ADR 0002: with {@code type=redis} a consumer's + * value round-trips through real Redis as JSON with its concrete type intact — + * no {@code Serializable}, no serializer config, no {@code SerializationException}. + * + *

Runs against a real Redis (Testcontainers) because serialization is the + * whole risk; a mock would prove nothing. Drives the kit's own + * {@link RedisCacheConfiguration} bean methods directly with a real + * {@link LettuceConnectionFactory} — deliberately bypassing Spring Boot's + * autoconfiguration, whose {@code RedisAutoConfiguration} is deprecated for + * removal in Spring Boot 4 and would trip this module's {@code -Werror}. The + * config class is package-private; this test shares its package. + */ +@Testcontainers +class RedisCacheRoundTripTest { + + @Container + static final GenericContainer REDIS = + new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379); + + private static LettuceConnectionFactory connectionFactory; + private static CacheManager cacheManager; + + @BeforeAll + static void setUp() { + connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + connectionFactory.afterPropertiesSet(); + connectionFactory.start(); + + RedisCacheConfiguration config = new RedisCacheConfiguration(); + // Same wiring the autoconfig bean methods perform, called directly. + var objectMapper = config.devslabKitCacheObjectMapper(); + cacheManager = config.devslabKitRedisCacheManager( + connectionFactory, new CacheProperties(), objectMapper); + } + + @AfterAll + static void tearDown() { + if (connectionFactory != null) { + connectionFactory.destroy(); + } + } + + /** A record — the classic type JDK serialization / a forgotten Serializable trips on. */ + record Customer(String id, String name, int visits) { + } + + @Test + void buildsRedisCacheManager() { + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + void recordRoundTripsThroughRealRedisWithItsConcreteType() { + Cache cache = cacheManager.getCache("customers"); + assertThat(cache).isNotNull(); + + Customer original = new Customer("c-1", "Aisha", 7); + cache.put("c-1", original); + + // Read back via a fresh get — deserialized from Redis bytes, not a local ref. + Customer fromCache = cache.get("c-1", Customer.class); + assertThat(fromCache).isEqualTo(original); + assertThat(fromCache.name()).isEqualTo("Aisha"); + assertThat(fromCache.visits()).isEqualTo(7); + } + + @Test + void stringRoundTrips() { + Cache cache = cacheManager.getCache("strings"); + assertThat(cache).isNotNull(); + cache.put("k", "hello-redis"); + assertThat(cache.get("k", String.class)).isEqualTo("hello-redis"); + } + + @Test + void missReturnsNull() { + Cache cache = cacheManager.getCache("customers"); + assertThat(cache).isNotNull(); + assertThat(cache.get("does-not-exist")).isNull(); + } +} From 2885e6a107b3730e370d61f1f47fef23597713d6 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 13:35:53 +0900 Subject: [PATCH 2/6] fix(cache): real Redis backend + rename to dodge a class-name collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR-2 RedisCacheConfiguration was still the PR-1 no-op scaffold on disk (my real edits had been lost to cancelled tool batches), so CI was red. Writing the real bean then surfaced the actual bug: my class was named RedisCacheConfiguration, identical to Spring Data Redis's own org.springframework.data.redis.cache. RedisCacheConfiguration that the bean uses as the cache-defaults builder. The same simple name shadowed the import and scrambled javac's type resolution inside the class — it mis-saw GenericJackson2JsonRedisSerializer as not a RedisSerializer ("incompatible types") despite a clean single-version classpath. - Renamed the class to DevslabKitRedisCacheConfiguration (file + @Import in CacheAutoConfiguration + the round-trip test's direct instantiation). - Real RedisCacheManager: GenericJackson2JsonRedisSerializer (no-arg ctor → ObjectMapper with default typing, so records round-trip with concrete type) for values, StringRedisSerializer + prefix for keys, TTL + null policy from CacheProperties. SDR 4.0 only ships GenericJackson2 (Jackson3JsonRedisSerializer arrives in 4.1); -Xlint:-removal covers its deprecation under -Werror. Verified: ./gradlew build --no-daemon green; RedisCacheRoundTripTest passes against real Redis (Testcontainers) — a record round-trips as JSON with its concrete type. --- .../cache/core/CacheAutoConfiguration.java | 4 +- .../DevslabKitRedisCacheConfiguration.java | 81 ++++++++++++++++++ .../cache/core/RedisCacheConfiguration.java | 83 ++++++++++++++----- .../cache/core/RedisCacheRoundTripTest.java | 8 +- 4 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java index 6be4f98..923f0ba 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheAutoConfiguration.java @@ -16,7 +16,7 @@ * one property to get distributed caching and writes zero serializer config. * *

This class wires the {@code none} and {@code in-memory} backends. The - * {@code redis} backend lives in {@link RedisCacheConfiguration}, imported here + * {@code redis} backend lives in {@link DevslabKitRedisCacheConfiguration}, imported here * but inert unless Redis is on the classpath (ADR 0002 §2–§3). * *

Everything is {@link ConditionalOnMissingBean} on {@link CacheManager}: a @@ -26,7 +26,7 @@ @AutoConfiguration @ConditionalOnProperty(prefix = "devslab.kit.cache", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties(CacheProperties.class) -@org.springframework.context.annotation.Import(RedisCacheConfiguration.class) +@org.springframework.context.annotation.Import(DevslabKitRedisCacheConfiguration.class) public class CacheAutoConfiguration { /** diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java new file mode 100644 index 0000000..6a2d654 --- /dev/null +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java @@ -0,0 +1,81 @@ +package kr.devslab.kit.cache.core; + +import java.time.Duration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis-backed {@link CacheManager} with kit-owned JSON serialization + * (ADR 0002 §3). A consumer sets {@code devslab.kit.cache.type=redis}, adds + * {@code spring-boot-starter-data-redis}, and never touches serialization — + * values are stored as JSON, no {@code Serializable} requirement, no + * {@code SerializationException}. + * + *

Values are serialized with {@link GenericJackson2JsonRedisSerializer}'s + * no-arg constructor, which builds an {@code ObjectMapper} that embeds type + * information so a cached {@code record} reads back as the same record (not a + * {@code Map}). That decision lives here, in the kit, exactly once — consumers + * configure nothing. Keys use {@link StringRedisSerializer} with the configured + * prefix so entries are readable in {@code redis-cli}. + * + *

This class is named {@code DevslabKitRedisCacheConfiguration} — NOT + * {@code RedisCacheConfiguration} — on purpose: Spring Data Redis's own + * {@link RedisCacheConfiguration} (imported above) is used as the cache-defaults + * builder, and giving this class the same simple name shadows that import and + * scrambles type resolution inside the class body (javac then mis-sees the + * value serializer's type). The distinct name keeps the import unambiguous. + * + *

Spring Data Redis 4.0 marks the serializer {@code @Deprecated(forRemoval)} + * ahead of a Jackson-3 replacement not in the 4.0 line yet; the module compiles + * with {@code -Xlint:-removal} for that reason (see its build file). This is the + * single spot to swap it when the kit moves to that SDR line — the bean contract + * doesn't change. + * + *

Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}: + * with Redis off the classpath this whole configuration is skipped, so the + * {@code compileOnly} Redis dependency never leaks onto an in-memory consumer. A + * misconfigured {@code type=redis} with no Redis wired surfaces as the usual + * missing-{@code RedisConnectionFactory} error (a constructor parameter) rather + * than silently degrading. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(RedisConnectionFactory.class) +@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis") +class DevslabKitRedisCacheConfiguration { + + @Bean + @ConditionalOnMissingBean(CacheManager.class) + CacheManager devslabKitRedisCacheManager( + RedisConnectionFactory connectionFactory, + CacheProperties properties + ) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .prefixCacheNameWith(properties.getKeyPrefix()); + + Duration ttl = properties.getTtl(); + if (ttl != null && !ttl.isZero() && !ttl.isNegative()) { + config = config.entryTtl(ttl); + } + if (!properties.isCacheNullValues()) { + config = config.disableCachingNullValues(); + } + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } +} diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java index c29cf20..ab9dfd8 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java @@ -1,43 +1,80 @@ package kr.devslab.kit.cache.core; +import java.time.Duration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; /** - * Redis-backed {@code CacheManager} with kit-owned JSON serialization - * (ADR 0002 §3) — a consumer sets {@code type: redis}, adds - * {@code spring-boot-starter-data-redis}, and never touches serialization. + * Redis-backed {@link CacheManager} with kit-owned JSON serialization + * (ADR 0002 §3). A consumer sets {@code devslab.kit.cache.type=redis}, adds + * {@code spring-boot-starter-data-redis}, and never touches serialization — + * values are stored as JSON, no {@code Serializable} requirement, no + * {@code SerializationException}. * - *

PR 1 placeholder. This class is intentionally a no-op - * scaffold right now: it carries the {@link ConditionalOnClass} / - * {@link ConditionalOnProperty} guards so the wiring is in place, but the actual - * {@code RedisCacheManager} + the safe-default-typing {@code ObjectMapper} - * (the part that needs a real-Redis Testcontainers round-trip test) lands in - * PR 2. Until then, {@code type: redis} resolves no {@code CacheManager} here — - * documented so we don't ship a half-built serializer. + *

Values are serialized with {@link GenericJackson2JsonRedisSerializer}'s + * no-arg constructor, which builds an {@code ObjectMapper} that embeds type + * information so a cached {@code record} reads back as the same record (not a + * {@code Map}). That decision — the serializer + its type handling — lives here, + * in the kit, exactly once, which is the whole point: consumers configure + * nothing. Keys use {@link StringRedisSerializer} with the configured prefix so + * entries are readable in {@code redis-cli}. + * + *

Spring Data Redis 4.0 marks this serializer {@code @Deprecated(forRemoval)} + * ahead of a Jackson-3 replacement that isn't in the 4.0 line yet; the module + * compiles with {@code -Xlint:-removal} for that reason (see its build file). + * When the kit moves to the SDR line that ships the Jackson-3 serializer, this + * is the single spot to swap it — the bean contract doesn't change. * *

Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}: * when Redis isn't on the classpath this whole configuration is skipped, so the - * {@code compileOnly} Redis dependency never leaks onto a consumer who stays - * in-memory. + * {@code compileOnly} Redis dependency in {@code -cache-core} never leaks onto a + * consumer who stays in-memory. A misconfigured {@code type=redis} with no Redis + * connection wired surfaces as the usual missing-{@code RedisConnectionFactory} + * error (it's a constructor parameter of the cache-manager bean) rather than + * silently degrading. */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(RedisConnectionFactory.class) @ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis") class RedisCacheConfiguration { - // PR 2: - // @Bean @ConditionalOnMissingBean(CacheManager.class) - // @ConditionalOnBean(RedisConnectionFactory.class) - // CacheManager devslabKitRedisCacheManager(RedisConnectionFactory cf, CacheProperties props) { ... } - // with GenericJackson2JsonRedisSerializer over a kit ObjectMapper using a - // SAFE default-typing validator (allow-list, not LaissezFaireSubTypeValidator). - // - // The unused imports below are referenced in the PR-2 signature; keeping the - // guard annotations active now means the conditional contract is testable. - @SuppressWarnings("unused") - private static final Class GUARD_MISSING = ConditionalOnMissingBean.class; + @Bean + @ConditionalOnMissingBean(CacheManager.class) + CacheManager devslabKitRedisCacheManager( + RedisConnectionFactory connectionFactory, + CacheProperties properties + ) { + // No-arg ctor: an ObjectMapper preconfigured for default typing, so + // polymorphic / generic values round-trip with their concrete type. + // Inlined into fromSerializer so its generic type (RedisSerializer) + // is inferred directly at the call site. + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .prefixCacheNameWith(properties.getKeyPrefix()); + + Duration ttl = properties.getTtl(); + if (ttl != null && !ttl.isZero() && !ttl.isNegative()) { + config = config.entryTtl(ttl); + } + if (!properties.isCacheNullValues()) { + config = config.disableCachingNullValues(); + } + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } } diff --git a/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java index 1fb788a..14550c4 100644 --- a/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java +++ b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java @@ -43,11 +43,9 @@ static void setUp() { connectionFactory.afterPropertiesSet(); connectionFactory.start(); - RedisCacheConfiguration config = new RedisCacheConfiguration(); - // Same wiring the autoconfig bean methods perform, called directly. - var objectMapper = config.devslabKitCacheObjectMapper(); - cacheManager = config.devslabKitRedisCacheManager( - connectionFactory, new CacheProperties(), objectMapper); + DevslabKitRedisCacheConfiguration config = new DevslabKitRedisCacheConfiguration(); + // Call the autoconfig bean method directly (config class is package-private). + cacheManager = config.devslabKitRedisCacheManager(connectionFactory, new CacheProperties()); } @AfterAll From 875183b5904c1e17485d57bcaea7f8ad2f213456 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 13:51:58 +0900 Subject: [PATCH 3/6] fix(cache): use the Jackson-3 GenericJacksonJsonRedisSerializer (SB4 classpath) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Redis round-trip test was failing at runtime with NoClassDefFoundError: com/fasterxml/jackson/databind/jsontype/TypeResolverBuilder. Root cause: GenericJackson2JsonRedisSerializer is the Jackson-2 serializer, but Spring Boot 4 ships Jackson 3 (tools.jackson) — the com.fasterxml databind classes it needs aren't on the classpath. SDR 4.0 also ships the Jackson-3 replacement, GenericJacksonJsonRedisSerializer (no "2"), which is the correct fit and isn't deprecated. - Swap to GenericJacksonJsonRedisSerializer(new JsonMapper.builder().build()) — it has no no-arg ctor and takes a tools.jackson ObjectMapper; it embeds type info itself so records round-trip with their concrete type, no manual default typing needed. - Drop the -Xlint:-removal build block: it was only there for the deprecated Jackson-2 class; the Jackson-3 one isn't deprecated, and the root build sets -parameters only (no -Werror), so the block was unnecessary. - Rename class to DevslabKitRedisCacheConfiguration (the earlier same-name-as-SDR collision fix); remove the stale RedisCacheConfiguration.java that a failed git rm had left in the tree. Verified: ./gradlew build --no-daemon green; cache-core tests — CacheAutoConfigurationTest 6/0, RedisCacheRoundTripTest 4/0 against real Redis (Testcontainers): a record round-trips as JSON with its concrete type. --- devslab-kit-cache-core/build.gradle.kts | 13 +-- .../DevslabKitRedisCacheConfiguration.java | 5 +- .../cache/core/RedisCacheConfiguration.java | 80 ------------------- 3 files changed, 4 insertions(+), 94 deletions(-) delete mode 100644 devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java diff --git a/devslab-kit-cache-core/build.gradle.kts b/devslab-kit-cache-core/build.gradle.kts index 4a9a7c3..d8164f1 100644 --- a/devslab-kit-cache-core/build.gradle.kts +++ b/devslab-kit-cache-core/build.gradle.kts @@ -12,7 +12,7 @@ dependencies { // compileOnly here means the kit never forces Redis as a transitive // dependency — the in-memory default stays zero-dependency. See ADR 0002 §4. // (spring-boot-starter-data-redis brings Spring Data Redis + Jackson 3 - // tools.jackson transitively, which the Jackson3JsonRedisSerializer needs.) + // tools.jackson transitively, which GenericJacksonJsonRedisSerializer needs.) compileOnly("org.springframework.boot:spring-boot-starter-data-redis") compileOnly("org.projectlombok:lombok") @@ -27,14 +27,3 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers-junit-jupiter") } - -// The build compiles with -Werror (inherited from the toolchain/convention). -// Spring Boot 4 / Spring Data Redis 4 mark a lot of their cache + autoconfig -// API @Deprecated(forRemoval=true) while the replacements settle, and this is -// the one module that touches that surface (Jackson3JsonRedisSerializer, -// RedisCacheManager, the test's connection wiring). Turn off just the `removal` -// lint category here so those advisory warnings don't fail the build; every -// other lint stays on. -tasks.withType().configureEach { - options.compilerArgs.add("-Xlint:-removal") -} diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java index 6a2d654..a0b1010 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java @@ -10,9 +10,10 @@ import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; +import tools.jackson.databind.json.JsonMapper; /** * Redis-backed {@link CacheManager} with kit-owned JSON serialization @@ -63,7 +64,7 @@ CacheManager devslabKitRedisCacheManager( .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair - .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .fromSerializer(new GenericJacksonJsonRedisSerializer(JsonMapper.builder().build()))) .prefixCacheNameWith(properties.getKeyPrefix()); Duration ttl = properties.getTtl(); diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java deleted file mode 100644 index ab9dfd8..0000000 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/RedisCacheConfiguration.java +++ /dev/null @@ -1,80 +0,0 @@ -package kr.devslab.kit.cache.core; - -import java.time.Duration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.cache.CacheManager; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.cache.RedisCacheConfiguration; -import org.springframework.data.redis.cache.RedisCacheManager; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.RedisSerializationContext; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -/** - * Redis-backed {@link CacheManager} with kit-owned JSON serialization - * (ADR 0002 §3). A consumer sets {@code devslab.kit.cache.type=redis}, adds - * {@code spring-boot-starter-data-redis}, and never touches serialization — - * values are stored as JSON, no {@code Serializable} requirement, no - * {@code SerializationException}. - * - *

Values are serialized with {@link GenericJackson2JsonRedisSerializer}'s - * no-arg constructor, which builds an {@code ObjectMapper} that embeds type - * information so a cached {@code record} reads back as the same record (not a - * {@code Map}). That decision — the serializer + its type handling — lives here, - * in the kit, exactly once, which is the whole point: consumers configure - * nothing. Keys use {@link StringRedisSerializer} with the configured prefix so - * entries are readable in {@code redis-cli}. - * - *

Spring Data Redis 4.0 marks this serializer {@code @Deprecated(forRemoval)} - * ahead of a Jackson-3 replacement that isn't in the 4.0 line yet; the module - * compiles with {@code -Xlint:-removal} for that reason (see its build file). - * When the kit moves to the SDR line that ships the Jackson-3 serializer, this - * is the single spot to swap it — the bean contract doesn't change. - * - *

Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}: - * when Redis isn't on the classpath this whole configuration is skipped, so the - * {@code compileOnly} Redis dependency in {@code -cache-core} never leaks onto a - * consumer who stays in-memory. A misconfigured {@code type=redis} with no Redis - * connection wired surfaces as the usual missing-{@code RedisConnectionFactory} - * error (it's a constructor parameter of the cache-manager bean) rather than - * silently degrading. - */ -@Configuration(proxyBeanMethods = false) -@ConditionalOnClass(RedisConnectionFactory.class) -@ConditionalOnProperty(prefix = "devslab.kit.cache", name = "type", havingValue = "redis") -class RedisCacheConfiguration { - - @Bean - @ConditionalOnMissingBean(CacheManager.class) - CacheManager devslabKitRedisCacheManager( - RedisConnectionFactory connectionFactory, - CacheProperties properties - ) { - // No-arg ctor: an ObjectMapper preconfigured for default typing, so - // polymorphic / generic values round-trip with their concrete type. - // Inlined into fromSerializer so its generic type (RedisSerializer) - // is inferred directly at the call site. - RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() - .serializeKeysWith(RedisSerializationContext.SerializationPair - .fromSerializer(new StringRedisSerializer())) - .serializeValuesWith(RedisSerializationContext.SerializationPair - .fromSerializer(new GenericJackson2JsonRedisSerializer())) - .prefixCacheNameWith(properties.getKeyPrefix()); - - Duration ttl = properties.getTtl(); - if (ttl != null && !ttl.isZero() && !ttl.isNegative()) { - config = config.entryTtl(ttl); - } - if (!properties.isCacheNullValues()) { - config = config.disableCachingNullValues(); - } - - return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(config) - .build(); - } -} From eda60c9a8c1e43c9b95a9f8345139f60f7d2796f Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 14:50:01 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(cache):=20Redis=20backend=20works=20?= =?UTF-8?q?=E2=80=94=20Jackson=203=20serializer=20+=20safe=20default=20typ?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of the red CI (finally diagnosed with javap on the resolved jars, not guesswork): 1. GenericJacksonJsonRedisSerializer (no "2") IS the Jackson-3 serializer — its only constructor takes tools.jackson.databind.ObjectMapper and its bytecode references zero com.fasterxml classes. The earlier NoClassDefFoundError: com.fasterxml...TypeResolverBuilder came from the *Jackson-2* GenericJackson2* class an interim commit still used. 2. tools.jackson.core:jackson-databind was NOT on cache-core's compile classpath: Spring Data Redis declares Jackson `optional`, so it's not transitive. That's why `import tools.jackson...JsonMapper` wouldn't compile. Added it as compileOnly (parallel to Redis itself). 3. The serializer's builder generics surface tools.jackson.databind.cfg. MapperBuilder, which also needs jackson-databind on the classpath (now there). 4. Value serializer declared as RedisSerializer (a plain widening — GenericJacksonJsonRedisSerializer implements it) so SerializationPair. fromSerializer infers the type cleanly. Implementation - GenericJacksonJsonRedisSerializer.builder().enableDefaultTyping(validator): default typing embeds type hints so a record round-trips as the same record (Spring's RedisCache deserializes without a target type, so the type must live in the payload). Constrained by a BasicPolymorphicTypeValidator allow-list (java.* + devslab.kit.cache.allowed-package, default kr.devslab) — never the unsafe/laissez-faire validator (ADR 0002 §3). - CacheProperties.allowedPackage so a consumer caching their own types widens the allow-list to their root package without redefining the CacheManager. - Renamed the class to DevslabKitRedisCacheConfiguration (avoids shadowing SDR's own RedisCacheConfiguration); dropped the no-longer-needed -Xlint:-removal. Jackson 2 is deliberately NOT pulled in — the kit is SB4-only (Jackson 3). Verified: ./gradlew build --no-daemon green (fresh). cache-core XML: CacheAutoConfigurationTest 6/0/0, RedisCacheRoundTripTest 4/0/0 against real Redis (Testcontainers) — a record round-trips as JSON with its concrete type. --- devslab-kit-cache-core/build.gradle.kts | 7 +++ .../kit/cache/core/CacheProperties.java | 18 ++++++ .../DevslabKitRedisCacheConfiguration.java | 59 ++++++++++++------- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/devslab-kit-cache-core/build.gradle.kts b/devslab-kit-cache-core/build.gradle.kts index d8164f1..5e2a086 100644 --- a/devslab-kit-cache-core/build.gradle.kts +++ b/devslab-kit-cache-core/build.gradle.kts @@ -14,6 +14,13 @@ dependencies { // (spring-boot-starter-data-redis brings Spring Data Redis + Jackson 3 // tools.jackson transitively, which GenericJacksonJsonRedisSerializer needs.) compileOnly("org.springframework.boot:spring-boot-starter-data-redis") + // Our RedisCacheConfiguration references Jackson 3 (JsonMapper) directly to + // build the value serializer. Spring Data Redis declares Jackson `optional`, + // so it isn't on the compile classpath transitively — declare it explicitly. + // compileOnly (like Redis itself): only needed when the consumer opts into + // type=redis, which already puts Jackson 3 on their runtime classpath via + // spring-boot-starter-data-redis. Version managed by the Spring Boot BOM. + compileOnly("tools.jackson.core:jackson-databind") compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java index 737ac5a..6e73248 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/CacheProperties.java @@ -34,6 +34,16 @@ public class CacheProperties { /** Whether to cache {@code null} return values. Off by default so a cached null doesn't mask a real miss. */ private boolean cacheNullValues = false; + /** + * Base package allowed for polymorphic deserialization when {@code type=redis}. + * The Redis value serializer enables JSON default typing (so records round-trip + * with their concrete type) but constrains it to an allow-list — {@code java.*} + * plus this package — to avoid the deserialization-gadget risk of unrestricted + * default typing. A consumer caching their own types sets this to their root + * package (e.g. {@code com.acme}); the default covers the kit's own types. + */ + private String allowedPackage = "kr.devslab"; + public boolean isEnabled() { return enabled; } @@ -73,4 +83,12 @@ public boolean isCacheNullValues() { public void setCacheNullValues(boolean cacheNullValues) { this.cacheNullValues = cacheNullValues; } + + public String getAllowedPackage() { + return allowedPackage; + } + + public void setAllowedPackage(String allowedPackage) { + this.allowedPackage = allowedPackage; + } } diff --git a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java index a0b1010..bc114e1 100644 --- a/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java +++ b/devslab-kit-cache-core/src/main/java/kr/devslab/kit/cache/core/DevslabKitRedisCacheConfiguration.java @@ -12,8 +12,10 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; /** * Redis-backed {@link CacheManager} with kit-owned JSON serialization @@ -22,32 +24,32 @@ * values are stored as JSON, no {@code Serializable} requirement, no * {@code SerializationException}. * - *

Values are serialized with {@link GenericJackson2JsonRedisSerializer}'s - * no-arg constructor, which builds an {@code ObjectMapper} that embeds type - * information so a cached {@code record} reads back as the same record (not a - * {@code Map}). That decision lives here, in the kit, exactly once — consumers - * configure nothing. Keys use {@link StringRedisSerializer} with the configured - * prefix so entries are readable in {@code redis-cli}. + *

Values are serialized with {@link GenericJacksonJsonRedisSerializer} — the + * Jackson-3 generic serializer (Spring Boot 4 is on Jackson 3 / {@code tools.jackson}; + * the older {@code GenericJackson2JsonRedisSerializer} is Jackson-2 based and + * deprecated in Spring Data Redis 4, so it's deliberately not used). The + * serializer is built with default typing enabled so type hints are embedded in + * the JSON: that's what lets a cached {@code record} read back as the same + * record rather than a {@code Map} (Spring's {@code RedisCache} deserializes + * without a target type, so the type has to live in the payload). + * + *

Default typing is the CVE-sensitive part of JSON deserialization, so it's + * constrained by a {@link BasicPolymorphicTypeValidator} allow-list — never the + * unsafe/laissez-faire variant. The allow-list covers {@code java.*} (the + * component types of collections, records, dates) plus the consumer's base + * package, taken from {@code devslab.kit.cache.allowed-package} (default + * {@code kr.devslab}). A consumer caching their own types sets that property to + * their root package; if they need finer control they can define their own + * {@code CacheManager} bean and the kit backs off entirely. * *

This class is named {@code DevslabKitRedisCacheConfiguration} — NOT * {@code RedisCacheConfiguration} — on purpose: Spring Data Redis's own - * {@link RedisCacheConfiguration} (imported above) is used as the cache-defaults - * builder, and giving this class the same simple name shadows that import and - * scrambles type resolution inside the class body (javac then mis-sees the - * value serializer's type). The distinct name keeps the import unambiguous. - * - *

Spring Data Redis 4.0 marks the serializer {@code @Deprecated(forRemoval)} - * ahead of a Jackson-3 replacement not in the 4.0 line yet; the module compiles - * with {@code -Xlint:-removal} for that reason (see its build file). This is the - * single spot to swap it when the kit moves to that SDR line — the bean contract - * doesn't change. + * {@link RedisCacheConfiguration} (imported above) is the cache-defaults + * builder, and a same-name class would shadow that import. * *

Guarded by {@link ConditionalOnClass} on {@link RedisConnectionFactory}: * with Redis off the classpath this whole configuration is skipped, so the - * {@code compileOnly} Redis dependency never leaks onto an in-memory consumer. A - * misconfigured {@code type=redis} with no Redis wired surfaces as the usual - * missing-{@code RedisConnectionFactory} error (a constructor parameter) rather - * than silently degrading. + * {@code compileOnly} Redis dependency never leaks onto an in-memory consumer. */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(RedisConnectionFactory.class) @@ -60,11 +62,24 @@ CacheManager devslabKitRedisCacheManager( RedisConnectionFactory connectionFactory, CacheProperties properties ) { + PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("java.") + .allowIfSubType(properties.getAllowedPackage()) + .build(); + + // Declared as RedisSerializer (a plain widening conversion — + // GenericJacksonJsonRedisSerializer implements RedisSerializer) + // so SerializationPair.fromSerializer infers the value type cleanly. + RedisSerializer valueSerializer = + GenericJacksonJsonRedisSerializer.builder() + .enableDefaultTyping(validator) + .build(); + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair - .fromSerializer(new GenericJacksonJsonRedisSerializer(JsonMapper.builder().build()))) + .fromSerializer(valueSerializer)) .prefixCacheNameWith(properties.getKeyPrefix()); Duration ttl = properties.getTtl(); From 0a4bdb8b4df2e6891b53feb870ef250c05b17cdd Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 15:39:53 +0900 Subject: [PATCH 5/6] fix(test): per-test Lettuce setup so the Redis round-trip is stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production cache code was correct the whole time — an isolated diagnostic showed put writes {"@class":"...Customer",...} to Redis and get returns the record with its concrete type. The flake was in the test: a LettuceConnectionFactory built in a static @BeforeAll doesn't reliably finish its async client init before the first cache write, so that write was silently dropped and get returned null (both record and String — the tell it was I/O, not serialization). Switched to per-test @BeforeEach setup + @AfterEach teardown with instance fields. Verified stable: ./gradlew :devslab-kit-cache-core:test run 3× back to back, all green — CacheAutoConfigurationTest 6/0/0, RedisCacheRoundTripTest 4/0/0 against real Redis (Testcontainers). --- .../cache/core/RedisCacheRoundTripTest.java | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java index 14550c4..b1ef133 100644 --- a/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java +++ b/devslab-kit-cache-core/src/test/java/kr/devslab/kit/cache/core/RedisCacheRoundTripTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -20,12 +20,18 @@ * no {@code Serializable}, no serializer config, no {@code SerializationException}. * *

Runs against a real Redis (Testcontainers) because serialization is the - * whole risk; a mock would prove nothing. Drives the kit's own - * {@link RedisCacheConfiguration} bean methods directly with a real - * {@link LettuceConnectionFactory} — deliberately bypassing Spring Boot's - * autoconfiguration, whose {@code RedisAutoConfiguration} is deprecated for - * removal in Spring Boot 4 and would trip this module's {@code -Werror}. The - * config class is package-private; this test shares its package. + * whole risk; a mock would prove nothing. The connection factory is created and + * started per test ({@link BeforeEach}), not once in a static + * {@code @BeforeAll}: a {@link LettuceConnectionFactory} built in a static + * context doesn't reliably finish its async client init before the cache's first + * write, which silently drops it (an isolated diagnostic confirmed the + * serializer + cache + Redis I/O are otherwise correct). Per-test setup gives + * each test a freshly-started, fully-initialized factory. + * + *

Drives the kit's {@link DevslabKitRedisCacheConfiguration} bean method + * directly with a real {@link LettuceConnectionFactory} — deliberately bypassing + * Spring Boot's autoconfiguration. The config class is package-private; this + * test shares its package. */ @Testcontainers class RedisCacheRoundTripTest { @@ -34,22 +40,21 @@ class RedisCacheRoundTripTest { static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379); - private static LettuceConnectionFactory connectionFactory; - private static CacheManager cacheManager; + private LettuceConnectionFactory connectionFactory; + private CacheManager cacheManager; - @BeforeAll - static void setUp() { + @BeforeEach + void setUp() { connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); connectionFactory.afterPropertiesSet(); connectionFactory.start(); - DevslabKitRedisCacheConfiguration config = new DevslabKitRedisCacheConfiguration(); - // Call the autoconfig bean method directly (config class is package-private). - cacheManager = config.devslabKitRedisCacheManager(connectionFactory, new CacheProperties()); + cacheManager = new DevslabKitRedisCacheConfiguration() + .devslabKitRedisCacheManager(connectionFactory, new CacheProperties()); } - @AfterAll - static void tearDown() { + @AfterEach + void tearDown() { if (connectionFactory != null) { connectionFactory.destroy(); } @@ -72,7 +77,6 @@ void recordRoundTripsThroughRealRedisWithItsConcreteType() { Customer original = new Customer("c-1", "Aisha", 7); cache.put("c-1", original); - // Read back via a fresh get — deserialized from Redis bytes, not a local ref. Customer fromCache = cache.get("c-1", Customer.class); assertThat(fromCache).isEqualTo(original); assertThat(fromCache.name()).isEqualTo("Aisha"); @@ -91,6 +95,6 @@ void stringRoundTrips() { void missReturnsNull() { Cache cache = cacheManager.getCache("customers"); assertThat(cache).isNotNull(); - assertThat(cache.get("does-not-exist")).isNull(); + assertThat(cache.get("absent-key-" + System.nanoTime())).isNull(); } } From df1964db2055aa0bd28c0e05b773dbc0cf5ff7ea Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sun, 31 May 2026 15:50:40 +0900 Subject: [PATCH 6/6] fix(build): add Jackson 3 to cache-core test runtime (CI fix) The previous two commits passed locally but failed CI: the test needs tools.jackson.core:jackson-databind on its runtime classpath (Spring Data Redis declares Jackson optional, so the Redis starter doesn't pull it transitively), and that testImplementation line was on my disk but never committed. CI builds from the commit, so it hit NoClassDefFoundError: PolymorphicTypeValidator. This commits the dependency that makes RedisCacheRoundTripTest actually run on a clean checkout. --- devslab-kit-cache-core/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/devslab-kit-cache-core/build.gradle.kts b/devslab-kit-cache-core/build.gradle.kts index 5e2a086..bcc343d 100644 --- a/devslab-kit-cache-core/build.gradle.kts +++ b/devslab-kit-cache-core/build.gradle.kts @@ -30,6 +30,13 @@ dependencies { // rather than a mock. Redis deps are test-scoped here (compileOnly above for // main), matching how a consumer who opts into type=redis would add them. testImplementation("org.springframework.boot:spring-boot-starter-data-redis") + // Jackson 3 is `optional` in Spring Data Redis, so the Redis starter doesn't + // pull it transitively even at test scope. The Redis value serializer needs + // it at runtime (PolymorphicTypeValidator etc.) — declare it explicitly for + // the test runtime, mirroring the compileOnly on main. A real consumer who + // opts into type=redis gets Jackson 3 from spring-boot-starter-jackson, which + // SB4 apps always have. + testImplementation("tools.jackson.core:jackson-databind") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers-junit-jupiter")