From f39a8a2877e2e291e0227c2ba2633c501759d5db Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:35:13 +0800 Subject: [PATCH 01/56] refactor(config): one assembly root per boundary, ownership by class The runtime root now owns the `resi-cache.enabled` gate alone and imports the enablement validation it protects; the second auto-configuration entry and its duplicate gate are gone. Internal classes registered by a boundary root's explicit import carry no component stereotype, so the runtime scan no longer needs class-name patterns for them, and the operator CLI names the beans it needs by class instead of scanning the runtime package. A class rename in either boundary now fails compilation instead of silently emptying a context: the operator root is excluded by class, and the CLI excludes the runtime auto-configuration by class rather than by a property string. --- docs/ARCHITECTURE.md | 13 ++++++++-- .../cache/RedisProxyCachingConfiguration.java | 6 ++--- .../cache/ResolvedMetricsConfiguration.java | 5 ++-- .../cache/SerializationMigrationEngine.java | 6 +++-- ...izationMigrationOperatorConfiguration.java | 20 +++++++++++++++ .../config/CachingEnablementValidation.java | 10 ++++---- .../config/RedisCacheAutoConfiguration.java | 22 ++++++++-------- .../cache/redis/config/package-info.java | 4 +-- .../migration/SerializationMigrationCli.java | 25 ++++++++----------- ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../resources/allowlist/public-surface.txt | 1 + 11 files changed, 71 insertions(+), 42 deletions(-) create mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7516c36e..1513bc32 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,8 +26,17 @@ RedisCacheAutoConfiguration `RedisCacheAutoConfiguration` is conditional on Redis classes and `resi-cache.enabled`; it does not add `@EnableCaching`. The internal component scan is deliberately limited to `io.github.davidhlp.spring.cache.redis.cache` -and excludes tests and operator-only/configuration seams listed in the source. -Host application packages are not scanned by the library. +and excludes test classes plus the operator-boundary assembly root, which is +named by class. Configurations registered by an explicit import carry no +component stereotype, so no name pattern stands in for class identity — a +class rename fails compilation instead of silently changing a context. Host +application packages are not scanned by the library. + +The operator CLI (`SerializationMigrationCli`) is the second assembly +boundary: its context names the internal migration beans by class through +`SerializationMigrationOperatorConfiguration` and excludes +`RedisCacheAutoConfiguration` by class, so it never assembles the cache/AOP +runtime and needs no enablement gate. ## Module ownership diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java index eb8e29e5..573bf374 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java @@ -8,7 +8,6 @@ import org.springframework.cache.interceptor.CacheOperationSource; import org.springframework.cache.interceptor.KeyGenerator; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; /** @@ -17,8 +16,10 @@ * 缺少用户提供的 {@code CacheManager} 时启用,但忽略库自身的 * {@code RedisProCacheManager}。这避免默认 manager 已注册后代理条件被误判为不满足; * 用户提供任意其他 {@code CacheManager} 时,默认 manager 与代理一起 back off。 + * + *

由 {@code RedisProCacheConfiguration} 显式 {@code @Import} 注册,故内层同样不带组件注解: + * 组件扫描不会重复注册该选择门。 */ -@Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) class RedisProxyCachingConfiguration { @@ -40,7 +41,6 @@ public CacheOperationSource redisCacheOperationSource( * user-provided {@code CacheManager} beans back off the library proxy, while * the library's own {@code RedisProCacheManager} remains ignored. */ - @Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnMissingBean( value = org.springframework.cache.CacheManager.class, diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetricsConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetricsConfiguration.java index 0e82d46f..4c232774 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetricsConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetricsConfiguration.java @@ -4,13 +4,14 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; /** * 统一解析 opt-in metrics 选择,供运行时和迁移 CLI 两条装配边界复用。 + * + *

由两条边界的装配根显式 {@code @Import}({@code RedisProCacheConfiguration} / + * {@code SerializationMigrationOperatorConfiguration}),故不带组件注解:组件扫描不会重复注册。 */ -@Configuration(proxyBeanMethods = false) class ResolvedMetricsConfiguration { @Bean diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java index 5fbf8a6a..789b9251 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java @@ -22,13 +22,15 @@ import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.types.Expiration; -import org.springframework.stereotype.Component; /** * Bounded, resumable legacy-value migration engine used by the operator CLI. + * + *

Registered by the operator-boundary assembly root + * ({@link SerializationMigrationOperatorConfiguration}); it carries no component + * stereotype so the runtime context never assembles it. */ @Slf4j -@Component class SerializationMigrationEngine implements io.github.davidhlp.spring.cache.redis.serialization.migration.SerializationMigrationCli.SerializationMigrationRunner { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java new file mode 100644 index 00000000..ead3434f --- /dev/null +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java @@ -0,0 +1,20 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Operator 边界装配根:按类点名迁移 CLI 上下文所需的内部 bean. + * + *

只由 operator 入口 {@code SerializationMigrationCli} 导入;运行时装配根 + * {@code RedisCacheAutoConfiguration} 按类排除本类,因此该边界不会进入运行时上下文。 + * 内部 bean 不带组件注解,由本类显式声明 —— 类改名会编译失败,而不是静默清空 CLI 上下文。 + */ +@Configuration(proxyBeanMethods = false) +@Import({ + SerializationMigrationEngine.class, + SecureJacksonSerializerFactory.class, + ResolvedMetricsConfiguration.class +}) +public class SerializationMigrationOperatorConfiguration { +} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/config/CachingEnablementValidation.java b/src/main/java/io/github/davidhlp/spring/cache/redis/config/CachingEnablementValidation.java index eaa3c9fe..e35728d1 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/config/CachingEnablementValidation.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/config/CachingEnablementValidation.java @@ -4,19 +4,19 @@ import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; /** * 缓存启用状态验证配置. * - *

在应用启动时检查是否已启用 @EnableCaching. + *

在应用启动时检查是否已启用 @EnableCaching。{@code resi-cache.enabled} 启用门由运行时 + * 装配根 {@link RedisCacheAutoConfiguration} 单一声明,本类只由该入口导入,不单独注册为 + * 自动配置 —— 关闭主开关时校验器同样不参与装配。 */ @Slf4j -@AutoConfiguration -@ConditionalOnProperty(prefix = "resi-cache", name = "enabled", matchIfMissing = true) +@Configuration(proxyBeanMethods = false) public class CachingEnablementValidation { /** diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/config/RedisCacheAutoConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/config/RedisCacheAutoConfiguration.java index 218eaf97..47de044f 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/config/RedisCacheAutoConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/config/RedisCacheAutoConfiguration.java @@ -3,6 +3,7 @@ +import io.github.davidhlp.spring.cache.redis.cache.SerializationMigrationOperatorConfiguration; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -10,12 +11,18 @@ import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; import org.springframework.data.redis.core.RedisOperations; /** - * Redis缓存自动配置主入口 + * Redis缓存自动配置主入口(运行时装配根) * *

职责: 1. 作为Redis缓存模块的配置入口点 2. 导入各个专门的配置类 3. 确保配置加载顺序正确 + * 4. 单一声明 {@code resi-cache.enabled} 启用门,并导入该门所保护的启用校验 + * + *

内部运行时包只扫描组件;由显式 {@code @Import} 注册的配置类不带组件注解,因此扫描不再 + * 维护类名正则 —— operator 边界装配根按类排除,类改名会编译失败而不是静默改变上下文。 + * {@code .*Test.*} 过滤仅用于隔离同包测试类,与 bean 归属无关。 * *

注意:@EnableCaching已移除,避免与用户应用中的其他@EnableCaching冲突。 * 用户应确保应用中已启用Spring Cache功能。 @@ -24,21 +31,16 @@ @AutoConfiguration(after = DataRedisAutoConfiguration.class) @ConditionalOnClass({RedisOperations.class}) @ConditionalOnProperty(prefix = "resi-cache", name = "enabled", matchIfMissing = true) +@Import(CachingEnablementValidation.class) @ComponentScan( basePackages = "io.github.davidhlp.spring.cache.redis.cache", excludeFilters = { @ComponentScan.Filter( - type = FilterType.REGEX, - pattern = ".*Test.*"), - @ComponentScan.Filter( - type = FilterType.REGEX, - pattern = ".*RedisProxyCachingConfiguration.*"), - @ComponentScan.Filter( - type = FilterType.REGEX, - pattern = ".*ResolvedMetricsConfiguration"), + type = FilterType.ASSIGNABLE_TYPE, + classes = SerializationMigrationOperatorConfiguration.class), @ComponentScan.Filter( type = FilterType.REGEX, - pattern = ".*SerializationMigrationEngine") + pattern = ".*Test.*") }) public class RedisCacheAutoConfiguration { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/config/package-info.java b/src/main/java/io/github/davidhlp/spring/cache/redis/config/package-info.java index 3af50f51..814b8bea 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/config/package-info.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/config/package-info.java @@ -3,9 +3,9 @@ * *

本包仅承载稳定自动配置/属性入口: *

*

具体装配类位于 package-private {@code cache} runtime。 */ diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCli.java b/src/main/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCli.java index d761f71d..705d5afc 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCli.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCli.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.davidhlp.spring.cache.redis.cache.SerializationMigrationOperatorConfiguration; +import io.github.davidhlp.spring.cache.redis.config.RedisCacheAutoConfiguration; import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import org.springframework.boot.Banner; import org.springframework.boot.WebApplicationType; @@ -12,9 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; /** * Standalone operator entry point for serialization migration. @@ -37,11 +38,7 @@ public static void main(String[] args) { CliConfiguration.class) .web(WebApplicationType.NONE) .bannerMode(Banner.Mode.OFF) - .properties( - "spring.main.lazy-initialization=true", - "spring.autoconfigure.exclude=" - + "io.github.davidhlp.spring.cache.redis.config." - + "RedisCacheAutoConfiguration") + .properties("spring.main.lazy-initialization=true") .run(args)) { SerializationMigrationReport report = context .getBean(SerializationMigrationRunner.class).migrate(); @@ -53,16 +50,14 @@ public static void main(String[] args) { } } - /** Minimal CLI context: Redis connection + migration beans, no cache/AOP runtime. */ + /** + * Operator 边界装配根:Redis 连接 + 迁移 bean,按类点名,不做包扫描; + * 运行时自动配置按类排除,CLI 上下文因此不会装配缓存/AOP 运行时。 + */ @Configuration(proxyBeanMethods = false) - @EnableAutoConfiguration + @EnableAutoConfiguration(exclude = RedisCacheAutoConfiguration.class) @EnableConfigurationProperties(RedisProCacheProperties.class) - @ComponentScan( - basePackages = "io.github.davidhlp.spring.cache.redis.cache", - useDefaultFilters = false, - includeFilters = @ComponentScan.Filter( - type = FilterType.REGEX, - pattern = ".*(SerializationMigrationEngine|SecureJacksonSerializerFactory|ResolvedMetricsConfiguration)")) + @Import(SerializationMigrationOperatorConfiguration.class) static class CliConfiguration { @Bean @ConditionalOnMissingBean(ObjectMapper.class) diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index b99f35d6..bd4305a1 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1 @@ io.github.davidhlp.spring.cache.redis.config.RedisCacheAutoConfiguration -io.github.davidhlp.spring.cache.redis.config.CachingEnablementValidation diff --git a/src/test/resources/allowlist/public-surface.txt b/src/test/resources/allowlist/public-surface.txt index 78ced53b..2e104666 100644 --- a/src/test/resources/allowlist/public-surface.txt +++ b/src/test/resources/allowlist/public-surface.txt @@ -5,6 +5,7 @@ annotation.RedisCaching cache.CacheOperationException cache.RedisProCache cache.RedisProCacheManager +cache.SerializationMigrationOperatorConfiguration cache.metrics.CacheMetrics chain.CacheHandler chain.ChainContinuation From 47f6e727155b5cdbcf491c8a57c31193ec57de8c Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:35:26 +0800 Subject: [PATCH 02/56] test(cache): assert assembly ownership by class, enumerate bean methods The contract test stops string-syncing exclusion patterns: it asserts the runtime scan names its one excluded boundary class by identity, that the only name pattern left is the same-package test-class filter, and that the auto-configuration imports resource registers exactly the runtime root by class name. The backoff invariant now enumerates @Bean methods instead of a hand-maintained list, so a new default bean without @ConditionalOnMissingBean fails. The CLI contract test proves the operator context assembles the migration beans without the runtime auto-config. --- ...edisProCacheConfigurationContractTest.java | 84 +++++++++++-------- ...SerializationMigrationCliContractTest.java | 10 ++- 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index 72943247..b901d681 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -22,6 +22,7 @@ import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.data.redis.connection.RedisConnectionFactory; import static org.assertj.core.api.Assertions.assertThat; @@ -41,9 +42,7 @@ void disabledMasterSwitch_skipsResiCacheAutoConfiguration() { @Test void disabledMasterSwitch_alsoSkipsMetricsConfiguration() { new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of( - RedisCacheAutoConfiguration.class, - CachingEnablementValidation.class)) + .withConfiguration(AutoConfigurations.of(RedisCacheAutoConfiguration.class)) .withPropertyValues( "resi-cache.enabled=false", "resi-cache.metrics.enabled=true") @@ -52,6 +51,7 @@ void disabledMasterSwitch_alsoSkipsMetricsConfiguration() { assertThat(context) .doesNotHaveBean( io.github.davidhlp.spring.cache.redis.cache.RedisCacheHealthIndicator.class); + // 启用门只在运行时装配根声明一次:关闭主开关即不再导入启用校验 assertThat(context) .doesNotHaveBean( CachingEnablementValidation.CachingEnabledValidator.class); @@ -137,17 +137,40 @@ void productionConfiguration_importsInternalConfigurationsExplicitly() { } @Test - void entry_componentScan_excludesOperatorAndExplicitlyImportedConfigurations() { + void entry_componentScan_excludesOperatorBoundaryByClass() { ComponentScan scan = RedisCacheAutoConfiguration.class.getAnnotation(ComponentScan.class); - assertThat(scan.excludeFilters()) - .anySatisfy(filter -> assertThat(filter.pattern()) - .containsExactly(".*RedisProxyCachingConfiguration.*")); - assertThat(scan.excludeFilters()) - .anySatisfy(filter -> assertThat(filter.pattern()) - .containsExactly(".*SerializationMigrationEngine")); - assertThat(scan.excludeFilters()) - .anySatisfy(filter -> assertThat(filter.pattern()) - .containsExactly(".*ResolvedMetricsConfiguration")); + assertThat(scan).isNotNull(); + assertThat(java.util.Arrays.stream(scan.excludeFilters()) + .filter(filter -> filter.type() == FilterType.ASSIGNABLE_TYPE) + .flatMap(filter -> java.util.Arrays.stream(filter.value())) + .toList()) + .containsExactly(SerializationMigrationOperatorConfiguration.class); + } + + @Test + void entry_componentScan_usesNoOwnershipNamePattern() { + // 仅保留同包测试类过滤;bean 归属不再由类名正则表达 + ComponentScan scan = RedisCacheAutoConfiguration.class.getAnnotation(ComponentScan.class); + assertThat(java.util.Arrays.stream(scan.excludeFilters()) + .filter(filter -> filter.type() == FilterType.REGEX) + .map(ComponentScan.Filter::pattern) + .toList()) + .containsExactly(".*Test.*"); + } + + @Test + void autoConfigurationImports_registerOnlyTheRuntimeRoot() throws Exception { + try (java.io.InputStream imports = RedisCacheAutoConfiguration.class.getResourceAsStream( + "/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports")) { + assertThat(imports).as("auto-configuration imports resource").isNotNull(); + assertThat(new java.io.BufferedReader( + new java.io.InputStreamReader(imports, java.nio.charset.StandardCharsets.UTF_8)) + .lines() + .map(String::trim) + .filter(line -> !line.isEmpty()) + .toList()) + .containsExactly(RedisCacheAutoConfiguration.class.getName()); + } } @Test @@ -327,26 +350,21 @@ void replaceableDefaults_backOffByContractType() { @Test void everyDefaultBeanDeclaresBackoff() { - String[] defaultBeanMethods = { - "methodMetadataResolver", - "cacheErrorHandler", - "cacheOperationResolver", - "bloomFilterConfig", - "bloomIFilter", - "redisProCacheWriter", - "defaultRedisCacheConfiguration", - "cacheManager", - "keyGenerator", - "cacheStatisticsCollector", - "systemClock", - "earlyExpirationExecutor" - }; - - for (String methodName : defaultBeanMethods) { - assertThat(conditionOn(methodName)) - .as("default bean method %s", methodName) - .isNotNull(); - } + java.util.List beanMethods = java.util.Arrays.stream( + RedisProCacheConfiguration.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(Bean.class)) + .toList(); + + assertThat(beanMethods).isNotEmpty(); + // 标准 observer 是叠加钩子(用户 observer 与它们共存),不是可替换默认 bean; + // 该集合由 standardObserverBeans_areDeclaredWithOrder 固定为 4 个。 + // 其余每个 @Bean 方法都必须按类型 back off —— 新增服务 bean 缺少注解除即失败。 + assertThat(beanMethods) + .filteredOn(method -> !io.github.davidhlp.spring.cache.redis.chain.observer + .ChainObserver.class.isAssignableFrom(method.getReturnType())) + .allSatisfy(method -> assertThat(method.getAnnotation(ConditionalOnMissingBean.class)) + .as("default bean method %s must back off by type", method.getName()) + .isNotNull()); } private ConditionalOnMissingBean conditionOn(String methodName) { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCliContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCliContractTest.java index c0169564..e44933ba 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCliContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/serialization/migration/SerializationMigrationCliContractTest.java @@ -15,20 +15,22 @@ class SerializationMigrationCliContractTest { void cliContext_resolvesMetricsChoiceWithUserRegistry() { new ApplicationContextRunner() .withUserConfiguration(SerializationMigrationCli.CliConfiguration.class) - .withPropertyValues( - "spring.autoconfigure.exclude=" - + "io.github.davidhlp.spring.cache.redis.config.RedisCacheAutoConfiguration", - "resi-cache.metrics.enabled=true") + .withPropertyValues("resi-cache.metrics.enabled=true") .withBean(MeterRegistry.class, SimpleMeterRegistry::new) .withBean(RedisConnectionFactory.class, () -> mock(RedisConnectionFactory.class)) .run(context -> { assertThat(context).hasNotFailed(); + // operator 装配根按类点名:迁移 bean 齐备,CLI 上下文才可用 assertThat(context) .hasSingleBean(SerializationMigrationCli.SerializationMigrationRunner.class); assertThat(context).hasBean("resolvedMetrics"); assertThat(context.getBean("resolvedMetrics")) .hasFieldOrPropertyWithValue( "meterRegistry", context.getBean(MeterRegistry.class)); + // 运行时自动配置被按类排除:CLI 上下文不装配缓存/AOP 运行时 + assertThat(context) + .doesNotHaveBean( + io.github.davidhlp.spring.cache.redis.cache.RedisProCacheManager.class); }); } } From 566d543feb92e1f978f2251697d04377792f2957 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:35:36 +0800 Subject: [PATCH 03/56] refactor(cache): give failure reporting one owner 23 failure paths in 13 files each re-applied the same prose rule by hand ("WARN/ERROR carries cacheName-or-fingerprint, never raw key or exception message; the full stack stays at DEBUG"), so the rule only existed per site and the two remediation commits that introduced it (e598693, 34fc53d) had to patch sites one by one. FailureReport now takes what failed (text + raw key + throwable) and emits the sanctioned pair; callers no longer choose levels, pair DEBUG with WARN, or format fingerprints. The privacy rule becomes structural: a raw key only enters as a key and is fingerprinted by the seam, a throwable only as a throwable and rendered as its type chain. Typed exception messages take the same fingerprint helper. Metric (one report per failure), classification and count-once semantics are untouched, and each call site keeps its own logger so log categories stay stable. Two wording tokens move to the shared context suffix: LoaderOrchestrator's documented CacheErrorHandler bypass keeps its behaviour (single redacted WARN, no metric) but now reports "cause=" like every other site, and the sites whose fingerprint label was ad hoc use the canonical "keyFingerprint=". --- docs/ARCHITECTURE.md | 8 +- .../cache/redis/cache/BloomSupport.java | 13 +- .../cache/redis/cache/CacheErrorHandler.java | 29 ++--- .../spring/cache/redis/cache/ChainEngine.java | 21 +-- .../redis/cache/DistributedLockManager.java | 39 ++---- .../cache/redis/cache/EarlyRefresh.java | 8 +- .../cache/redis/cache/FailureDiagnostics.java | 10 +- .../cache/redis/cache/FailureReport.java | 120 ++++++++++++++++++ .../cache/redis/cache/LoaderOrchestrator.java | 7 +- .../cache/redis/cache/RedisBloomIFilter.java | 12 +- .../cache/redis/cache/RefreshRetryPolicy.java | 20 +-- .../cache/SerializationMigrationEngine.java | 18 +-- .../cache/SerializationPreFlightProbe.java | 6 +- .../spring/cache/redis/cache/SyncRole.java | 28 ++-- .../spring/cache/redis/cache/SyncSupport.java | 15 ++- .../ThreadPoolEarlyExpirationExecutor.java | 11 +- .../cache/RedisProCacheLoadPathTest.java | 2 +- 17 files changed, 206 insertions(+), 161 deletions(-) create mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureReport.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7516c36e..b131bfed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,9 +89,11 @@ current documented behavior in `COMPATIBILITY.md`. `CacheResult` carries typed operation outcomes internally. - `LoaderOrchestrator` owns the shared read → load → write-back protocol. A successful loaded value is returned even when write-back fails. -- `CacheErrorHandler` owns count-once failure reporting for chain failures; the - failure metric uses finite operation/kind/strategy dimensions and diagnostics - omit raw keys at WARN/ERROR. +- `FailureReport` owns the one failure-reporting shape: a WARN/ERROR carrying + only cacheName or the key fingerprint plus the exception type chain, paired + with a DEBUG line holding the full stack; `CacheErrorHandler` owns count-once + reporting for chain failures on top of it, and the failure metric uses finite + operation/kind/strategy dimensions. - `SecureJacksonRedisSerializer` owns whitelist-backed serialization and the `{version, payload}` envelope. Refresh metadata required by policy/CAS is persisted; process-local monotonic time is not. diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BloomSupport.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BloomSupport.java index 5980e056..3239fbf3 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BloomSupport.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BloomSupport.java @@ -55,9 +55,8 @@ public boolean mightContain(final String cacheName, final String key) { try { return bloomIFilter.mightContain(cacheName, key); } catch (Exception ex) { - log.error("Bloom filter mightContain failed, defaulting to may-contain: cacheName={}, cause={}", - cacheName, FailureDiagnostics.sanitizedFailure(ex)); - log.debug("Bloom filter mightContain failure detail: cacheName={}", cacheName, ex); + FailureReport.error(log, "Bloom filter mightContain failed, defaulting to may-contain", + cacheName, null, ex); return true; } } @@ -74,9 +73,7 @@ public void add(final String cacheName, final String key) { try { bloomIFilter.add(cacheName, key); } catch (Exception ex) { - log.error("Bloom filter add failed: cacheName={}, cause={}", - cacheName, FailureDiagnostics.sanitizedFailure(ex)); - log.debug("Bloom filter add failure detail: cacheName={}", cacheName, ex); + FailureReport.error(log, "Bloom filter add failed", cacheName, null, ex); } } @@ -92,9 +89,7 @@ public void clear(final String cacheName) { try { bloomIFilter.clear(cacheName); } catch (Exception ex) { - log.error("Bloom filter clear failed: cacheName={}, cause={}", - cacheName, FailureDiagnostics.sanitizedFailure(ex)); - log.debug("Bloom filter clear failure detail: cacheName={}", cacheName, ex); + FailureReport.error(log, "Bloom filter clear failed", cacheName, null, ex); } } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheErrorHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheErrorHandler.java index d820b60f..c73c0beb 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheErrorHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheErrorHandler.java @@ -90,11 +90,9 @@ static void finalizeFailure( return; } if (strategyFor(operation) != ErrorStrategy.FAIL_FAST) { - log.warn("Cache {} failed; continuing best-effort: cacheName={}, kind={}, cause={}", - operation, - cacheName, - result.failureKind(), - FailureDiagnostics.sanitizedFailure(result.cause())); + FailureReport.warn(log, + "Cache " + operation + " failed; continuing best-effort, kind=" + result.failureKind(), + cacheName, null, result.cause()); return; } throw new CacheOperationException( @@ -158,24 +156,21 @@ private CacheResult handleException( case FAIL_FAST -> { // Key-privacy contract:ERROR 不打印 raw key / 异常 message(可能嵌 key); // 完整栈(含 cause message)仅留 DEBUG 供开发诊断 - log.error("Cache {} failed: cacheName={}, kind={}, cause={}", - operationName, cacheName, failureKind, - e == null ? "null" : e.getClass().getSimpleName()); - log.debug("Cache {} failure detail: cacheName={}, kind={}", - operationName, cacheName, failureKind, e); + FailureReport.error(log, "Cache " + operationName + " failed, kind=" + failureKind, + cacheName, null, e); yield result; } case GRACEFUL_DEGRADATION -> { - // Key-privacy contract:WARN 不打印 raw key / exception message - log.warn("Cache {} failed, degrading to miss: cacheName={}, kind={}, cause={}", - operationName, cacheName, failureKind, - e == null ? "null" : e.getClass().getSimpleName()); + FailureReport.warn(log, + "Cache " + operationName + " failed, degrading to miss, kind=" + failureKind, + cacheName, null, e); yield result; } case SILENT -> { - log.warn("Cache {} failed, best-effort removal continues: cacheName={}, kind={}, cause={}", - operationName, cacheName, failureKind, - e == null ? "null" : e.getClass().getSimpleName()); + FailureReport.warn(log, + "Cache " + operationName + " failed, best-effort removal continues, kind=" + + failureKind, + cacheName, null, e); yield result; } }; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java index 9910a225..8dcc3123 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java @@ -364,15 +364,10 @@ private void runPostProcess(CacheResult mainResult) { log.debug("Post-processing executed for: {}", CacheHandlerChain.handlerTag(handler)); } catch (Exception e) { - // Key-privacy contract: ERROR includes cacheName and exception types only; - // 完整栈留 DEBUG(异常 message 可能内嵌 key)。 - log.error("Post-processing failed for: {}, operation: {}, cacheName: {}, cause={}", - CacheHandlerChain.handlerTag(handler), - context.getOperation(), - context.getCacheName(), - FailureDiagnostics.sanitizedFailure(e)); - log.debug("Post-processing failure detail: cacheName={}", - context.getCacheName(), e); + FailureReport.error(log, + "Post-processing failed for " + CacheHandlerChain.handlerTag(handler) + + ", operation: " + context.getOperation(), + context.getCacheName(), null, e); } } } @@ -421,12 +416,8 @@ void finish(String hookName, Object[] scopeTokens, Object result, } private void logFailure(ChainObserver observer, String hookName, Exception ex) { - // Key-privacy contract: ERROR renders only exception types; - log.error("Observer {} {} failed: {}", - observer.getClass().getSimpleName(), hookName, - FailureDiagnostics.sanitizedFailure(ex)); - log.debug("Observer {} {} failure detail", - observer.getClass().getSimpleName(), hookName, ex); + FailureReport.error(log, + "Observer " + observer.getClass().getSimpleName() + " " + hookName + " failed", ex); } @FunctionalInterface diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DistributedLockManager.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DistributedLockManager.java index 8538ea8f..aaccf0d8 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DistributedLockManager.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DistributedLockManager.java @@ -64,11 +64,8 @@ public Optional tryAcquire(final String key, final long timeoutSecon try { boolean acquired = lock.tryLock(timeoutSeconds, leaseTimeSeconds, TimeUnit.SECONDS); if (!acquired) { - // Key-privacy contract: WARN includes only keyFingerprint, never raw key / lockKey - log.warn( - "Failed to acquire distributed lock within {}s: keyFingerprint={}", - timeoutSeconds, - FailureDiagnostics.keyFingerprint(key)); + FailureReport.warn(log, "Failed to acquire distributed lock within " + timeoutSeconds + "s", + null, key); return Optional.empty(); } @@ -77,14 +74,9 @@ public Optional tryAcquire(final String key, final long timeoutSecon return Optional.of(new RedissonLockHandle(lock, key)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - // Key-privacy contract: ERROR and exception messages never include raw key - log.error("Interrupted while waiting for distributed lock: keyFingerprint={}, cause={}", - FailureDiagnostics.keyFingerprint(key), - FailureDiagnostics.sanitizedFailure(e)); - log.debug("Distributed lock wait interrupted detail: keyFingerprint={}", - FailureDiagnostics.keyFingerprint(key), e); + FailureReport.error(log, "Interrupted while waiting for distributed lock", null, key, e); throw new RuntimeException("Interrupted while waiting for distributed lock: keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key), e); + + FailureReport.fingerprint(key), e); } } @@ -178,27 +170,20 @@ public void close() { return; } catch (Exception e) { if (attempt == MAX_UNLOCK_RETRIES) { - // Key-privacy contract: ERROR/WARN includes only keyFingerprint - log.error("Failed to release distributed lock after {} attempts: " - + "keyFingerprint={}, cause={}", - MAX_UNLOCK_RETRIES, - FailureDiagnostics.keyFingerprint(key), - FailureDiagnostics.sanitizedFailure(e)); - log.debug("Distributed lock release failure detail: keyFingerprint={}", - FailureDiagnostics.keyFingerprint(key), e); + FailureReport.error(log, + "Failed to release distributed lock after " + MAX_UNLOCK_RETRIES + " attempts", + null, key, e); return; } - log.warn("Failed to release distributed lock on attempt {}, retrying in {}ms: " - + "keyFingerprint={}", - attempt, UNLOCK_RETRY_INTERVAL_MS, - FailureDiagnostics.keyFingerprint(key)); + FailureReport.warn(log, + "Failed to release distributed lock on attempt " + attempt + + ", retrying in " + UNLOCK_RETRY_INTERVAL_MS + "ms", + null, key); try { Thread.sleep(UNLOCK_RETRY_INTERVAL_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - log.error("Interrupted while retrying lock release: keyFingerprint={}, cause={}", - FailureDiagnostics.keyFingerprint(key), - FailureDiagnostics.sanitizedFailure(ie)); + FailureReport.error(log, "Interrupted while retrying lock release", null, key, ie); return; } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/EarlyRefresh.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/EarlyRefresh.java index ac6f7220..fccd532c 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/EarlyRefresh.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/EarlyRefresh.java @@ -267,13 +267,7 @@ void performAsyncRefresh(String redisKey, String cacheName, CachedValue captured log.debug("Async early-expiration skipped: value changed: {}", redisKey); } } catch (Exception ex) { - // Key-privacy contract: ERROR includes only cacheName + keyFingerprint + exception type chain — - // 异常 message / 栈可能内嵌 raw key(如 Cache.ValueRetrievalException),故不进 ERROR; - // 完整栈留 DEBUG 供诊断。 - log.error("Async early-expiration failed: cacheName={}, keyFingerprint={}, cause={}", - cacheName, FailureDiagnostics.keyFingerprint(redisKey), - FailureDiagnostics.sanitizedFailure(ex)); - log.debug("Async early-expiration failure detail: cacheName={}", cacheName, ex); + FailureReport.error(log, "Async early-expiration failed", cacheName, redisKey, ex); } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureDiagnostics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureDiagnostics.java index 30d396ae..00158815 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureDiagnostics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureDiagnostics.java @@ -6,20 +6,24 @@ import org.springframework.lang.Nullable; /** - * 失败诊断的 key 隐私 helper(key-privacy contract)— 唯一 key 脱敏单点。 + * 失败诊断的 key 隐私 helper(key-privacy contract)— 指纹与类型链的 primitive 单点。 * *

契约:WARN/ERROR 与 typed exception message 不得出现 raw key;配置级低基数的 * {@code cacheName} 保留用于关联。当一条诊断既没有 cacheName、又需要与 DEBUG 原始日志关联时, * 用 {@link #keyFingerprint} 输出内容指纹替代 raw key。 * + *

唯一生产入口是 {@link FailureReport} —— 级别配对、WARN/ERROR 组装与异常消息里的 + * 指纹取用都在那里;本类只保留两套 primitive(指纹 / 类型链),便于独立阅读与测试。 + * *

为什么原样保留 fingerprint 算法:String 形态取 {@code Integer.toHexString(key.hashCode())}, * 字节形态取 {@code Integer.toHexString(Arrays.hashCode(key))} —— 两者都是「该形态下的内容哈希」, * 且字节形态与既有 serialization migration WARN 输出一致(该路径已先于本 helper 使用同一形式)。 * 这是关联令牌,不是安全边界 —— 它只保证日志与异常消息不携带 raw key,低熵 key 可被暴力 * 反推;需要强不可逆性时另议(不在 §15 要求内)。 * - *

deletion test:删掉本 helper → fingerprint 形式在 5 个类里各写一遍并各自漂移 - * (migration 引擎的 byte[] 版与锁/刷新路径的 String 版会再次分叉)。 + *

deletion test:删掉本 helper → 两套 primitive 内联进 {@link FailureReport}, + * 「哪些形态可关联、为何不做解码」的契约埋进日志装配代码,typed exception message 路径 + * 也会再次各自拼装指纹。 */ final class FailureDiagnostics { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureReport.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureReport.java new file mode 100644 index 00000000..9505d8a5 --- /dev/null +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FailureReport.java @@ -0,0 +1,120 @@ +package io.github.davidhlp.spring.cache.redis.cache; + + + + +import org.slf4j.Logger; + +/** + * 失败上报唯一 owner — key-privacy contract 的单点实现。 + * + *

契约:一次失败恰好产生「一条 WARN/ERROR + 一条配对 DEBUG」。WARN/ERROR 只携带 + * cacheName(配置级低基数)或 key 内容指纹,以及异常类型链(不含 message —— message 可能 + * 内嵌 raw key);含 message 的完整堆栈只留在 DEBUG。 + * + *

调用方契约:只说「什么失败了」—— 描述文本 + cacheName / raw key + 异常。级别选择、 + * 指纹格式化、类型链渲染全部由本类完成:raw key 只能以 raw 形态传入并被 + * {@link FailureDiagnostics#keyFingerprint} 转成指纹,异常只能以 {@link Throwable} 传入并被 + * {@link FailureDiagnostics#sanitizedFailure} 转成类型链 —— raw key 与异常 message 在 API + * 形态上无法进入 WARN/ERROR。typed exception message 需要指纹时同样经 {@link #fingerprint} + * 取用,调用点不自行拼装指纹。 + * + *

为什么日志走调用方 logger:保留各调用点的 log category(运维按类过滤、测试按类 + * 捕获该失败点),本类不引入自己的 category。 + * + *

deletion test:删掉本 seam → 「级别配对 + 指纹 + 类型链」的规则退回各调用点手写, + * WARN 与 DEBUG 再次各自漂移(见 remediation 提交 e598693 / 34fc53d)。 + */ +final class FailureReport { + + private FailureReport() { + } + + /** + * key 内容指纹(String 形态) —— 与 WARN/ERROR 上下文同一实现,供 typed exception message 使用。 + * + * @param key 缓存 key / 锁 key(可为 null → {@code "null"}) + * @return 16 进制内容指纹 + */ + static String fingerprint(String key) { + return FailureDiagnostics.keyFingerprint(key); + } + + /** + * key 内容指纹(byte[] 形态) —— 与 WARN/ERROR 上下文同一实现。 + * + * @param key key 字节(可为 null → {@code "null"}) + * @return 16 进制内容指纹 + */ + static String fingerprint(byte[] key) { + return FailureDiagnostics.keyFingerprint(key); + } + + static void warn(Logger log, String what, String cacheName, Object key) { + emit(log, false, what, cacheName, key, null); + } + + static void warn(Logger log, String what, Throwable failure) { + emit(log, false, what, null, null, failure); + } + + static void warn(Logger log, String what, String cacheName, Object key, Throwable failure) { + emit(log, false, what, cacheName, key, failure); + } + + static void error(Logger log, String what, Throwable failure) { + emit(log, true, what, null, null, failure); + } + + static void error(Logger log, String what, String cacheName, Object key, Throwable failure) { + emit(log, true, what, cacheName, key, failure); + } + + private static void emit(Logger log, + boolean error, + String what, + String cacheName, + Object key, + Throwable failure) { + String context = contextSuffix(cacheName, key); + String message = failure == null + ? what + context + : what + context + (context.isEmpty() ? ": " : ", ") + + "cause=" + FailureDiagnostics.sanitizedFailure(failure); + if (error) { + log.error(message); + } else { + log.warn(message); + } + if (failure != null) { + log.debug("{} detail{}", what, context, failure); + } + } + + /** 组装 {@code ": cacheName=..., keyFingerprint=..."};无可用关联字段时返回空串。 */ + private static String contextSuffix(String cacheName, Object key) { + StringBuilder sb = new StringBuilder(); + if (cacheName != null) { + sb.append(": cacheName=").append(cacheName); + } + String fingerprint = contextFingerprint(key); + if (fingerprint != null) { + sb.append(sb.isEmpty() ? ": " : ", ").append("keyFingerprint=").append(fingerprint); + } + return sb.toString(); + } + + /** + * raw key(String / byte[] 两种既有形态)转内容指纹。其他类型视为不可关联 —— 不输出, + * 避免把任意 {@code toString} 当作令牌打进日志。 + */ + private static String contextFingerprint(Object key) { + if (key instanceof String stringKey) { + return FailureDiagnostics.keyFingerprint(stringKey); + } + if (key instanceof byte[] byteKey) { + return FailureDiagnostics.keyFingerprint(byteKey); + } + return null; + } +} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java index 132d744d..b7351590 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java @@ -278,11 +278,8 @@ static LoadOutcome readThrough( } catch (IllegalArgumentException configError) { return new LoadFailed<>(configError); } catch (RuntimeException writeBackFailure) { - log.warn( - "Cache write-back failed after successful load; returning loaded value: " - + "cacheName={}, failure={}", - cacheName, - FailureDiagnostics.sanitizedFailure(writeBackFailure)); + FailureReport.warn(log, "Cache write-back failed after successful load; returning loaded value", + cacheName, null, writeBackFailure); return new LoadedWithWriteBackFailure<>(loaded, writeBackFailure); } catch (Throwable cause) { return new LoadFailed<>(cause); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java index fa4644b7..a3184461 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java @@ -89,9 +89,7 @@ public void add(String cacheName, String key) { key, Arrays.toString(positions)); } catch (Exception e) { - log.error("Bloom filter add failed: cacheName={}, cause={}", - cacheName, FailureDiagnostics.sanitizedFailure(e)); - log.debug("Bloom filter add failure detail: cacheName={}", cacheName, e); + FailureReport.error(log, "Bloom filter add failed", cacheName, null, e); if (addFailureCounter != null) { addFailureCounter.increment(); } @@ -137,9 +135,7 @@ public boolean mightContain(String cacheName, String key) { log.debug("Bloom filter hit (might exist): cacheName={}, key={}", cacheName, key); return true; } catch (Exception e) { - log.error("Bloom filter check failed: cacheName={}, cause={}", - cacheName, FailureDiagnostics.sanitizedFailure(e)); - log.debug("Bloom filter check failure detail: cacheName={}", cacheName, e); + FailureReport.error(log, "Bloom filter check failed", cacheName, null, e); if (checkFailureCounter != null) { checkFailureCounter.increment(); } @@ -159,9 +155,7 @@ public void clear(String cacheName) { redisTemplate.delete(bloomKey); log.debug("Bloom filter deleted: cacheName={}", cacheName); } catch (Exception e) { - log.error("Bloom filter delete failed: cacheName={}, cause={}", - cacheName, FailureDiagnostics.sanitizedFailure(e)); - log.debug("Bloom filter delete failure detail: cacheName={}", cacheName, e); + FailureReport.error(log, "Bloom filter delete failed", cacheName, null, e); } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshRetryPolicy.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshRetryPolicy.java index a5c22268..d0b681eb 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshRetryPolicy.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshRetryPolicy.java @@ -47,21 +47,16 @@ public void executeWithRetry(String key, Runnable task) { return; // 成功,退出 } catch (Exception ex) { lastException = ex; - // Key-privacy contract: WARN/ERROR includes only keyFingerprint, never raw key - log.warn("Async early-expiration failed (attempt {}/{}): keyFingerprint={}, cause={}", - attempt, MAX_RETRY_COUNT, FailureDiagnostics.keyFingerprint(key), - FailureDiagnostics.sanitizedFailure(ex)); - log.debug("Async early-expiration failure detail: attempt {}/{}", - attempt, MAX_RETRY_COUNT, ex); + FailureReport.warn(log, + "Async early-expiration failed (attempt " + attempt + "/" + MAX_RETRY_COUNT + ")", + null, key, ex); if (attempt < MAX_RETRY_COUNT) { try { Thread.sleep(RETRY_DELAY_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - log.warn("Retry interrupted, continuing with next attempt: keyFingerprint={}, cause={}", - FailureDiagnostics.keyFingerprint(key), - FailureDiagnostics.sanitizedFailure(ie)); + FailureReport.warn(log, "Retry interrupted, continuing with next attempt", null, key, ie); continue; // 继续下一次重试而非退出循环 } } @@ -70,11 +65,8 @@ public void executeWithRetry(String key, Runnable task) { // 所有重试都失败 if (lastException != null) { - log.error("Async early-expiration failed after {} attempts: keyFingerprint={}, cause={}", - MAX_RETRY_COUNT, FailureDiagnostics.keyFingerprint(key), - FailureDiagnostics.sanitizedFailure(lastException)); - log.debug("Async early-expiration final failure detail ({} attempts)", - MAX_RETRY_COUNT, lastException); + FailureReport.error(log, "Async early-expiration failed after " + MAX_RETRY_COUNT + " attempts", + null, key, lastException); throw new RuntimeException( "Pre-refresh failed after " + MAX_RETRY_COUNT + " attempts", lastException); } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java index 5fbf8a6a..2371d200 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java @@ -130,11 +130,7 @@ private void migrateSource(RedisConnection connection, byte[] key, MutableReport } catch (Exception ex) { report.failed++; record("failed"); - // Key-privacy contract: WARN omits raw key and exception message (it may contain the key); - // only the type chain and fingerprint remain; the full stack stays at DEBUG. - log.warn("[ResiCache] Serialization migration rejected key fingerprint={}, cause={}", - keyFingerprint(key), FailureDiagnostics.sanitizedFailure(ex)); - log.debug("[ResiCache] Serialization migration rejection detail", ex); + FailureReport.warn(log, "[ResiCache] Serialization migration rejected key", null, key, ex); } } @@ -180,9 +176,7 @@ private void rollbackBackup(RedisConnection connection, byte[] backupKey, } catch (Exception ex) { report.failed++; record("failed"); - log.warn("[ResiCache] Serialization rollback rejected key fingerprint={}, cause={}", - keyFingerprint(backupKey), FailureDiagnostics.sanitizedFailure(ex)); - log.debug("[ResiCache] Serialization rollback rejection detail", ex); + FailureReport.warn(log, "[ResiCache] Serialization rollback rejected key", null, backupKey, ex); } } @@ -326,14 +320,6 @@ private static boolean endsWith(byte[] key, String suffix) { return true; } - /** - * key 内容指纹 — 委托 {@link FailureDiagnostics} 的单一实现(key-privacy contract), - * 使 migration 路径与锁/刷新路径的指纹形式不再各自漂移。 - */ - private static String keyFingerprint(byte[] key) { - return FailureDiagnostics.keyFingerprint(key); - } - @FunctionalInterface private interface KeyConsumer { void accept(byte[] key); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationPreFlightProbe.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationPreFlightProbe.java index 361c0607..e1b32e05 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationPreFlightProbe.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationPreFlightProbe.java @@ -82,10 +82,8 @@ void scanAndReport() { } } } catch (Exception e) { - log.warn("[ResiCache] Serialization pre-flight probe failed to scan Redis " - + "(non-fatal): {}", - FailureDiagnostics.sanitizedFailure(e)); - log.debug("[ResiCache] Serialization pre-flight probe failure detail", e); + FailureReport.warn(log, + "[ResiCache] Serialization pre-flight probe failed to scan Redis (non-fatal)", e); return; } if (nonEnvelope > 0) { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java index a487723a..3b119c57 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java @@ -93,15 +93,14 @@ public T run() { failure = e; } catch (final InterruptedException e) { Thread.currentThread().interrupt(); - // Key-privacy contract: exception message omits raw key and keeps keyFingerprint failure = new IllegalStateException( "Thread interrupted while acquiring distributed lock: keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key), e); + + FailureReport.fingerprint(key), e); } catch (final Throwable t) { // Error 仍按原语义重新抛出,但先完成 future 并清理 owner state。 failure = new IllegalStateException( "Distributed-lock work failed: keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key), t); + + FailureReport.fingerprint(key), t); if (t instanceof Error error) { throw error; } @@ -143,12 +142,11 @@ public T run() { long timeoutSeconds = timeout.seconds(); try { if (timeoutSeconds <= 0 && !leader.isDone()) { - // Key-privacy contract: exception message omits raw key and keeps keyFingerprint throw new IllegalStateException( "In-flight single-flight loader still running; waitTimeoutSeconds=" + timeoutSeconds + " <= 0 — follower refuses to wait (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")"); + + FailureReport.fingerprint(key) + ")"); } final Object value = (timeoutSeconds > 0) ? leader.get(timeoutSeconds, TimeUnit.SECONDS) @@ -158,7 +156,7 @@ public T run() { throw new IllegalStateException( "Timed out after " + timeoutSeconds + "s waiting for in-flight single-flight loader (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")", e); + + FailureReport.fingerprint(key) + ")", e); } catch (final ExecutionException e) { // leader 的原始异常:RuntimeException 原样抛,保留调用方既有 catch 语义 final Throwable cause = (e.getCause() != null) ? e.getCause() : e; @@ -169,12 +167,12 @@ public T run() { throw err; } throw new RuntimeException("In-flight single-flight loader failed (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")", cause); + + FailureReport.fingerprint(key) + ")", cause); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException( "Thread interrupted while waiting for in-flight loader (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")", e); + + FailureReport.fingerprint(key) + ")", e); } } } @@ -216,7 +214,7 @@ public T run() { Thread.currentThread().interrupt(); throw new IllegalStateException( "Thread interrupted while acquiring distributed lock: keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key), e); + + FailureReport.fingerprint(key), e); } finally { state.exit(key); } @@ -261,10 +259,10 @@ static T run(Logger log, try (LockStack lockStack = new LockStack(log)) { for (LockManager manager : distributedManagers) { manager.tryAcquire(key, timeout.seconds()).ifPresentOrElse(lockStack::push, () -> { - // Key-privacy contract: WARN includes only keyFingerprint - log.warn("Lock manager {} failed to acquire distributed lock: keyFingerprint={}", - manager.getClass().getSimpleName(), - FailureDiagnostics.keyFingerprint(key)); + FailureReport.warn(log, + "Lock manager " + manager.getClass().getSimpleName() + + " failed to acquire distributed lock", + null, key); throw new RuntimeException("Failed to acquire distributed lock"); }); } @@ -299,9 +297,7 @@ public void close() { try { handle.close(); } catch (Exception e) { - log.error("Failed to release distributed lock: failure={}", - FailureDiagnostics.sanitizedFailure(e)); - log.debug("Distributed lock release failure detail", e); + FailureReport.error(log, "Failed to release distributed lock", null, key, e); } } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java index 9a1e0640..55d8f4fd 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java @@ -152,9 +152,10 @@ static T executeRoleWork(Logger log, SyncStateAccess state) throws InterruptedException { if (distributedManagers.isEmpty()) { if (properties.getSyncLock().isLocalOnly()) { - log.warn("protection.degraded=local-only: sync=true 但无分布式锁后端, " - + "已按 local-only=true 降级为单 JVM 同步 (keyFingerprint={})", - FailureDiagnostics.keyFingerprint(key)); + FailureReport.warn(log, + "protection.degraded=local-only: sync=true 但无分布式锁后端, " + + "已按 local-only=true 降级为单 JVM 同步", + null, key); return state.executeLocalOnly(key, timeout, work); } // Key-privacy contract: exception message omits raw key. @@ -162,7 +163,7 @@ static T executeRoleWork(Logger log, "sync=true 已声明但无分布式锁后端 (无 RedissonClient / LockManager bean)。" + "拒绝静默退化为单 JVM synchronized (多实例下无法防击穿)。" + "请引入 Redisson, 或显式设 resi-cache.sync-lock.local-only=true 接受单实例降级。" - + " [keyFingerprint=" + FailureDiagnostics.keyFingerprint(key) + "]"); + + " [keyFingerprint=" + FailureReport.fingerprint(key) + "]"); } return SyncRoleLockExecutor.run(log, key, timeout, work, distributedManagers); } @@ -292,18 +293,18 @@ private static void awaitPredecessor(String key, throw new IllegalStateException( "Timed out after " + timeoutSeconds + "s waiting for the local-only predecessor (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")", e); + + FailureReport.fingerprint(key) + ")", e); } catch (final ExecutionException e) { // 前驱 future 只会 complete(null);兜底避免把 checked 异常漏给调用方。 throw new IllegalStateException( "Local-only predecessor failed (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")", + + FailureReport.fingerprint(key) + ")", e.getCause() != null ? e.getCause() : e); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException( "Thread interrupted while waiting for the local-only predecessor (keyFingerprint=" - + FailureDiagnostics.keyFingerprint(key) + ")", e); + + FailureReport.fingerprint(key) + ")", e); } } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ThreadPoolEarlyExpirationExecutor.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ThreadPoolEarlyExpirationExecutor.java index 6670833c..12fe865e 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ThreadPoolEarlyExpirationExecutor.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ThreadPoolEarlyExpirationExecutor.java @@ -167,14 +167,9 @@ public void submit(String key, Runnable task) { inFlight.remove(k, created); metrics.recordCompleted(); if (throwable != null) { - // Key-privacy contract: ERROR includes only keyFingerprint - log.error("Async early-expiration failed after all retries: " - + "keyFingerprint={}, cause={}", - FailureDiagnostics.keyFingerprint(k), - FailureDiagnostics.sanitizedFailure(throwable)); - log.debug("Async early-expiration failure detail: " - + "keyFingerprint={}", - FailureDiagnostics.keyFingerprint(k), throwable); + FailureReport.error(log, + "Async early-expiration failed after all retries", + null, k, throwable); } }); return created; diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java index 904d521c..ba72ead7 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java @@ -350,6 +350,6 @@ private void assertSingleCanonicalWarning(ListAppender appender) .containsExactly( "Cache write-back failed after successful load; returning loaded value: " + "cacheName=" + CACHE_NAME - + ", failure=CacheOperationException <- IllegalStateException"); + + ", cause=CacheOperationException <- IllegalStateException"); } } From fa482794ce0bb08c99a51a44fe0c5fb3aa89af62 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:35:50 +0800 Subject: [PATCH 04/56] test(cache): retarget the key-privacy contract at the seam FailureLogKeyPrivacyTest enumerated the rule one method at a time (591 lines, one appender plus one fragment list per site) and asserted DEBUG wording the seam now owns, so it grew with every new failure path. FailureReportTest tests the rule once (one WARN/ERROR plus one stacked DEBUG, raw key and exception message never rendered, context assembled from what is available) and keeps thin per-site coverage for the shapes production actually crosses: Bloom fail-open, refresh retries, local-only degradation and observer isolation. --- .../redis/cache/FailureLogKeyPrivacyTest.java | 591 ------------------ .../cache/redis/cache/FailureReportTest.java | 293 +++++++++ 2 files changed, 293 insertions(+), 591 deletions(-) delete mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java deleted file mode 100644 index 17b835d8..00000000 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java +++ /dev/null @@ -1,591 +0,0 @@ -package io.github.davidhlp.spring.cache.redis.cache; - - - - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; -import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; -import io.github.davidhlp.spring.cache.redis.chain.CacheResult; -import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; -import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; -import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; -import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; -import io.github.davidhlp.spring.cache.redis.protection.bloom.filter.BloomIFilter; -import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.time.Clock; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.slf4j.LoggerFactory; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * 失败诊断 key 隐私回归(key-privacy contract)。 - * - *

契约:WARN/ERROR 日志与 typed exception message 不得出现 raw key; - * 配置级低基数的 {@code cacheName} 或 {@link FailureDiagnostics#keyFingerprint} 关联令牌可保留。 - * - *

本测试覆盖此前直接打印 raw key 的路径:分布式锁获取/释放、single-flight 角色失败、 - * 异步提前过期重试、链后置处理。每条路径用 logback {@link ListAppender} 捕获实际日志事件断言。 - */ -@DisplayName("Failure Log Key Privacy Tests (key-privacy contract)") -class FailureLogKeyPrivacyTest { - - /** 必须不出现的原始 key(测试专用哨兵值)。 */ - private static final String SECRET_KEY = "secret-customer-key-42"; - - private ListAppender attach(Class loggerOwner) { - return attach(loggerOwner.getName()); - } - - private ListAppender attach(String loggerName) { - Logger logger = (Logger) LoggerFactory.getLogger(loggerName); - ListAppender appender = new ListAppender<>(); - appender.start(); - logger.addAppender(appender); - return appender; - } - - private void detach(Class loggerOwner, ListAppender appender) { - detach(loggerOwner.getName(), appender); - } - - private void detach(String loggerName, ListAppender appender) { - ((Logger) LoggerFactory.getLogger(loggerName)).detachAppender(appender); - } - - /** - * 临时把某个 logger 提升到 DEBUG —— Spring 测试默认 INFO,否则「栈保留在 DEBUG」的契约不可断言。 - * 返回原 level(null 表示继承),调用方在 finally 里还原。 - */ - private Level enableDebug(Class loggerOwner) { - Logger logger = (Logger) LoggerFactory.getLogger(loggerOwner); - Level previous = logger.getLevel(); - logger.setLevel(Level.DEBUG); - return previous; - } - - private void restoreLevel(Class loggerOwner, Level previous) { - ((Logger) LoggerFactory.getLogger(loggerOwner)).setLevel(previous); - } - - private List errorEvents(ListAppender captured) { - return captured.list.stream() - .filter(event -> event.getLevel() == Level.ERROR) - .toList(); - } - - /** - * 断言失败点逐条产生 ERROR 且各自带稳定操作文本 —— 只断言「不含 raw key」会放过 - * 「把 ERROR 日志整段删掉」的回归;每个 fragment 必须命中不同事件。 - */ - private void assertErrorSites(ListAppender captured, String... fragments) { - List errors = errorEvents(captured); - assertThat(errors) - .as("每个失败点必须恰好产生一条 ERROR") - .hasSize(fragments.length); - List unmatched = new ArrayList<>(errors); - for (String fragment : fragments) { - ILoggingEvent matching = unmatched.stream() - .filter(event -> event.getFormattedMessage().contains(fragment)) - .findFirst() - .orElse(null); - assertThat(matching) - .as("缺少失败点 ERROR 文本: %s", fragment) - .isNotNull(); - unmatched.remove(matching); - } - assertThat(errors) - .as("ERROR 必须渲染异常类型链") - .allMatch(event -> event.getFormattedMessage().contains("IllegalStateException")); - assertThat(warnAndErrorText(captured)) - .as("WARN/ERROR 不得包含 raw key 或异常 message(key-privacy contract)") - .doesNotContain(SECRET_KEY) - .doesNotContain("boom"); - } - - private void assertDebugKeepsStack( - ListAppender captured, String... fragments) { - List debug = captured.list.stream() - .filter(event -> event.getLevel() == Level.DEBUG - && event.getThrowableProxy() != null) - .toList(); - assertThat(debug) - .as("每个失败点的完整栈必须保留在 DEBUG 供关联") - .hasSize(fragments.length); - List unmatched = new ArrayList<>(debug); - for (String fragment : fragments) { - ILoggingEvent matching = unmatched.stream() - .filter(event -> event.getFormattedMessage().contains(fragment)) - .findFirst() - .orElse(null); - assertThat(matching) - .as("缺少失败点 DEBUG 栈文本: %s", fragment) - .isNotNull(); - unmatched.remove(matching); - } - } - - /** - * 拼接全部 WARN/ERROR 事件的完整渲染:格式化消息 + 每个 throwable 的类型与 message - * (含 cause 链)。 - * - *

只断言格式化消息是不够的 —— SLF4J 会把异常栈(含 message)一并打印,而 - * {@code Cache.ValueRetrievalException} 的 message 内嵌 raw key。故本 helper 把 - * throwable 的 message 也算进「诊断文本」。 - */ - private String warnAndErrorText(ListAppender captured) { - StringBuilder sb = new StringBuilder(); - for (ILoggingEvent event : captured.list) { - if (!event.getLevel().isGreaterOrEqual(Level.WARN)) { - continue; - } - sb.append(event.getFormattedMessage()).append('\n'); - for (ch.qos.logback.classic.spi.IThrowableProxy proxy = event.getThrowableProxy(); - proxy != null; - proxy = proxy.getCause()) { - sb.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n'); - for (ch.qos.logback.classic.spi.StackTraceElementProxy frame - : proxy.getStackTraceElementProxyArray()) { - sb.append(" at ").append(frame.getSTEAsString()).append('\n'); - } - } - } - return sb.toString(); - } - - @Test - @DisplayName("keyFingerprint:与 raw key 不同、稳定、null-safe,byte[] 按字节哈希") - void keyFingerprint_isStableTokenNotRawKey() { - assertThat(FailureDiagnostics.keyFingerprint(SECRET_KEY)) - .isNotEqualTo(SECRET_KEY) - .isEqualTo(FailureDiagnostics.keyFingerprint(SECRET_KEY)); - assertThat(FailureDiagnostics.keyFingerprint((String) null)).isEqualTo("null"); - assertThat(FailureDiagnostics.keyFingerprint((byte[]) null)).isEqualTo("null"); - - byte[] bytes = SECRET_KEY.getBytes(java.nio.charset.StandardCharsets.UTF_8); - assertThat(FailureDiagnostics.keyFingerprint(bytes)) - .as("字节形态按字节哈希(不经过 UTF-8 解码,避免非法序列碰撞)") - .isNotEqualTo(SECRET_KEY) - .isEqualTo(FailureDiagnostics.keyFingerprint(bytes)); - - byte[] withReplacementChar = SECRET_KEY.getBytes(java.nio.charset.StandardCharsets.UTF_8); - assertThat(FailureDiagnostics.keyFingerprint(new byte[] {(byte) 0xFF, (byte) 0xFE})) - .as("不同字节序列不得因解码替换而碰撞") - .isNotEqualTo(FailureDiagnostics.keyFingerprint(new byte[] {(byte) 0xFE, (byte) 0xFF})); - } - - @Test - @DisplayName("RefreshRetryPolicy:重试耗尽后 WARN/ERROR 不含 raw key") - void refreshRetryPolicy_exhaustedRetries_omitsRawKey() { - ListAppender captured = attach(RefreshRetryPolicy.class); - try { - RefreshRetryPolicy policy = new RefreshRetryPolicy(); - AtomicInteger attempts = new AtomicInteger(); - - assertThatThrownBy(() -> policy.executeWithRetry(SECRET_KEY, () -> { - attempts.incrementAndGet(); - // 异常 message 故意内嵌 raw key:WARN/ERROR 不得把它渲染出来 - throw new IllegalStateException("redis down for key " + SECRET_KEY); - })).isInstanceOf(RuntimeException.class); - - assertThat(attempts.get()).isEqualTo(RefreshRetryPolicy.MAX_RETRY_COUNT); - assertThat(warnAndErrorText(captured)) - .as("WARN/ERROR 不得包含 raw key(key-privacy contract)") - .doesNotContain(SECRET_KEY) - .contains(FailureDiagnostics.keyFingerprint(SECRET_KEY)); - } finally { - detach(RefreshRetryPolicy.class, captured); - } - } - - @Test - @DisplayName("ChainEngine:post-process 失败 ERROR 不含 raw key(带 cacheName)") - void chainEngine_postProcessFailure_omitsRawKey() { - ListAppender captured = attach(ChainEngine.class); - try { - ChainEngine engine = new ChainEngine(); - CacheContext context = CacheContext.of(CacheInput.builder() - .operation(CacheOperation.GET) - .cacheName("privacy-cache") - .redisKey(SECRET_KEY) - .actualKey(SECRET_KEY) - .build()); - CacheHandler failing = new CacheHandler() { - @Override - public HandlerResult handle(CacheContext ctx) { - return HandlerResult.continueChain(); - } - - @Override - public boolean requiresPostProcess(CacheContext ctx) { - return true; - } - - @Override - public void afterChainExecution(CacheContext ctx, CacheResult result) { - throw new IllegalStateException("post-process boom for key " + SECRET_KEY); - } - }; - - engine.execute(List.of(failing), context); - - assertThat(warnAndErrorText(captured)) - .doesNotContain(SECRET_KEY) - .contains("privacy-cache"); - } finally { - detach(ChainEngine.class, captured); - } - } - @Test - @DisplayName("ChainEngine:post-process 判定失败也不泄露 raw key") - void chainEngine_postProcessPredicateFailure_omitsRawKey() { - ListAppender captured = attach(ChainEngine.class); - try { - ChainEngine engine = new ChainEngine(); - CacheContext context = CacheContext.of(CacheInput.builder() - .operation(CacheOperation.GET) - .cacheName("privacy-cache") - .redisKey(SECRET_KEY) - .actualKey(SECRET_KEY) - .build()); - CacheHandler failing = new CacheHandler() { - @Override - public HandlerResult handle(CacheContext ctx) { - return HandlerResult.continueChain(); - } - - @Override - public boolean requiresPostProcess(CacheContext ctx) { - throw new IllegalStateException("post-process predicate key " + SECRET_KEY); - } - }; - - CacheResult result = engine.execute(List.of(failing), context); - - assertThat(result.isSuccess()).isTrue(); - assertThat(warnAndErrorText(captured)) - .doesNotContain(SECRET_KEY) - .contains("privacy-cache"); - } finally { - detach(ChainEngine.class, captured); - } - } - - - @Test - @DisplayName("ChainEngine:observer 失败 ERROR 只渲染异常类型链,栈保留在 DEBUG") - void chainEngine_observerFailure_omitsRawKey() { - ListAppender captured = attach(ChainEngine.class); - Level previous = enableDebug(ChainEngine.class); - try { - ChainEngine engine = new ChainEngine(); - engine.addObserver(new ChainObserver() { - @Override - public Object onChainStart(CacheContext context) { - throw new IllegalStateException("observer boom for key " + SECRET_KEY); - } - }); - CacheContext context = CacheContext.of(CacheInput.builder() - .operation(CacheOperation.GET) - .cacheName("privacy-cache") - .redisKey(SECRET_KEY) - .actualKey(SECRET_KEY) - .build()); - - engine.execute(List.of(new CacheHandler() { - @Override - public HandlerResult handle(CacheContext ctx) { - return HandlerResult.terminate(CacheResult.success()); - } - }), context); - - assertThat(warnAndErrorText(captured)) - .as("observer 失败的 ERROR 不得渲染异常 message/栈(key-privacy contract)") - .doesNotContain(SECRET_KEY); - assertErrorSites(captured, "onChainStart failed"); - assertDebugKeepsStack(captured, "onChainStart failure detail"); - } finally { - detach(ChainEngine.class, captured); - restoreLevel(ChainEngine.class, previous); - } - } - - @Test - @DisplayName("BloomSupport:三处 fail-open ERROR 只渲染类型链,栈保留在 DEBUG") - void bloomSupport_failOpen_omitsRawKey() { - ListAppender captured = attach(BloomSupport.class); - Level previous = enableDebug(BloomSupport.class); - try { - BloomIFilter broken = new BloomIFilter() { - @Override - public void add(String cacheName, String key) { - throw new IllegalStateException("bloom add boom for key " + SECRET_KEY); - } - - @Override - public boolean mightContain(String cacheName, String key) { - throw new IllegalStateException("bloom check boom for key " + SECRET_KEY); - } - - @Override - public void clear(String cacheName) { - throw new IllegalStateException("bloom clear boom for key " + SECRET_KEY); - } - }; - BloomSupport support = new BloomSupport(broken); - - assertThat(support.mightContain("privacy-cache", SECRET_KEY)) - .as("fail-open 行为必须保留") - .isTrue(); - support.add("privacy-cache", SECRET_KEY); - support.clear("privacy-cache"); - - assertErrorSites(captured, - "mightContain failed, defaulting to may-contain", - "Bloom filter add failed", - "Bloom filter clear failed"); - assertDebugKeepsStack(captured, - "Bloom filter mightContain failure detail", - "Bloom filter add failure detail", - "Bloom filter clear failure detail"); - } finally { - detach(BloomSupport.class, captured); - restoreLevel(BloomSupport.class, previous); - } - } - - @Test - @DisplayName("RedisBloomIFilter:三个失败点 ERROR 只渲染类型链,栈保留在 DEBUG") - void redisBloomIFilter_failures_omitRawKey() { - ListAppender captured = attach(RedisBloomIFilter.class); - Level previous = enableDebug(RedisBloomIFilter.class); - try { - @SuppressWarnings("unchecked") - RedisTemplate redisTemplate = mock(RedisTemplate.class); - when(redisTemplate.executePipelined(any(RedisCallback.class))) - .thenThrow(new IllegalStateException("bloom redis boom for key " + SECRET_KEY)); - when(redisTemplate.delete(anyString())) - .thenThrow(new IllegalStateException("bloom redis delete boom for key " + SECRET_KEY)); - SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); - RedisBloomIFilter filter = new RedisBloomIFilter( - redisTemplate, new BloomFilterConfig("bf:", 4096, 3, 64), meterRegistry); - filter.init(); - - filter.add("privacy-cache", SECRET_KEY); - assertThat(filter.mightContain("privacy-cache", SECRET_KEY)) - .as("check 失败必须 fail-open") - .isTrue(); - filter.clear("privacy-cache"); - - assertErrorSites(captured, - "Bloom filter add failed", - "Bloom filter check failed", - "Bloom filter delete failed"); - assertDebugKeepsStack(captured, - "Bloom filter add failure detail", - "Bloom filter check failure detail", - "Bloom filter delete failure detail"); - assertThat(meterRegistry.get("bloomsift.add.failures").counter().count()) - .as("add 失败计数必须仍然自增") - .isEqualTo(1.0); - assertThat(meterRegistry.get("bloomsift.check.failures").counter().count()) - .as("check 失败计数必须仍然自增") - .isEqualTo(1.0); - } finally { - detach(RedisBloomIFilter.class, captured); - restoreLevel(RedisBloomIFilter.class, previous); - } - } - - @Test - @DisplayName("DistributedLockManager:获取超时 WARN 与被中断 ERROR/异常消息不含 raw key 与 lockKey") - void distributedLockManager_failures_omitRawKey() throws InterruptedException { - ListAppender captured = attach(DistributedLockManager.class); - try { - RedisProCacheProperties properties = new RedisProCacheProperties(); - RLock notAcquired = mock(RLock.class); - when(notAcquired.tryLock(anyLong(), anyLong(), any())).thenReturn(false); - DistributedLockManager manager = managerWithLock(properties, notAcquired); - - assertThat(manager.tryAcquire(SECRET_KEY, 1)).isEmpty(); - - assertThat(warnAndErrorText(captured)) - .doesNotContain(SECRET_KEY) - .doesNotContain(manager.buildLockKey(SECRET_KEY)) - .contains(FailureDiagnostics.keyFingerprint(SECRET_KEY)); - - RLock interrupted = mock(RLock.class); - when(interrupted.tryLock(anyLong(), anyLong(), any())) - .thenThrow(new InterruptedException("interrupted")); - DistributedLockManager interruptedManager = managerWithLock(properties, interrupted); - - try { - assertThatThrownBy(() -> interruptedManager.tryAcquire(SECRET_KEY, 1)) - .isInstanceOf(RuntimeException.class) - .hasMessageNotContaining(SECRET_KEY); - } finally { - Thread.interrupted(); - } - - assertThat(warnAndErrorText(captured)).doesNotContain(SECRET_KEY); - } finally { - detach(DistributedLockManager.class, captured); - } - } - - private DistributedLockManager managerWithLock(RedisProCacheProperties properties, RLock lock) { - DistributedLockManager template = new DistributedLockManager(mock(RedissonClient.class), properties); - RedissonClient client = mock(RedissonClient.class); - when(client.getLock(template.buildLockKey(SECRET_KEY))).thenReturn(lock); - return new DistributedLockManager(client, properties); - } - - @Test - @DisplayName("SyncSupport:fail-fast 异常消息与 local-only 降级 WARN 不含 raw key") - void syncSupport_failureDiagnostics_omitRawKey() { - // leader 角色的 WARN 走 SyncRole$Leader 自己的 logger;startup WARN 走 SyncSupport。 - ListAppender captured = attach(SyncSupport.class); - ListAppender leaderCaptured = attach(SyncRoleLeaderLogger.NAME); - try { - RedisProCacheProperties failFastProperties = new RedisProCacheProperties(); - SyncSupport failFast = new SyncSupport(new ArrayList<>(), failFastProperties); - - assertThatThrownBy(() -> failFast.executeSync(SECRET_KEY, () -> "v", 5)) - .isInstanceOf(IllegalStateException.class) - .hasMessageNotContaining(SECRET_KEY) - .hasMessageContaining(FailureDiagnostics.keyFingerprint(SECRET_KEY)); - - RedisProCacheProperties localOnlyProperties = new RedisProCacheProperties(); - localOnlyProperties.getSyncLock().setLocalOnly(true); - SyncSupport localOnly = new SyncSupport(new ArrayList<>(), localOnlyProperties); - - assertThat(localOnly.executeSync(SECRET_KEY, () -> "v", 5)).isEqualTo("v"); - - assertThat(warnAndErrorText(captured)).doesNotContain(SECRET_KEY); - assertThat(warnAndErrorText(leaderCaptured)) - .as("local-only 降级 WARN 必须可关联但不含 raw key") - .doesNotContain(SECRET_KEY) - .contains(FailureDiagnostics.keyFingerprint(SECRET_KEY)); - } finally { - detach(SyncSupport.class, captured); - detach(SyncRoleLeaderLogger.NAME, leaderCaptured); - } - } - - @Test - @DisplayName("SyncRole:锁管理器获取失败 WARN 不含 raw key") - void syncRole_lockManagerAcquireFailure_omitsRawKey() { - ListAppender captured = attach(SyncRoleLeaderLogger.NAME); - try { - RedisProCacheProperties properties = new RedisProCacheProperties(); - LockManager refusing = new LockManager() { - @Override - public Optional tryAcquire(String key, long timeoutSeconds) { - return Optional.empty(); - } - - @Override - public int getOrder() { - return 0; - } - }; - SyncSupport support = new SyncSupport(new ArrayList<>(List.of(refusing)), properties); - - assertThatThrownBy(() -> support.executeSync(SECRET_KEY, () -> "v", 5)) - .isInstanceOf(RuntimeException.class) - .hasMessageNotContaining(SECRET_KEY); - - assertThat(warnAndErrorText(captured)) - .as("获取锁失败的 WARN 必须被本测试捕获(否则断言空转)") - .contains("failed to acquire distributed lock") - .doesNotContain(SECRET_KEY); - } finally { - detach(SyncRoleLeaderLogger.NAME, captured); - } - } - - @Test - @DisplayName("SyncRole:锁释放失败 ERROR 只含异常类型链,不含 raw key 或异常 message") - void syncRole_lockReleaseFailure_sanitizesError() { - ListAppender captured = attach(SyncRoleLeaderLogger.NAME); - try { - LockManager releasingFailure = new LockManager() { - @Override - public Optional tryAcquire(String key, long timeoutSeconds) { - return Optional.of(() -> { - throw new IllegalStateException("release failed for key " + SECRET_KEY); - }); - } - - @Override - public int getOrder() { - return 0; - } - }; - - SyncSupport support = new SyncSupport(List.of(releasingFailure), - new RedisProCacheProperties()); - - assertThat(support.executeSync(SECRET_KEY, () -> "v", 5)).isEqualTo("v"); - assertThat(warnAndErrorText(captured)) - .contains("Failed to release distributed lock") - .doesNotContain(SECRET_KEY) - .doesNotContain("release failed for key"); - } finally { - detach(SyncRoleLeaderLogger.NAME, captured); - } - } - - /** SyncRole.Leader 的 logger 名(嵌套类在包外不可直接引用,避免测试依赖其可见性)。 */ - private static final class SyncRoleLeaderLogger { - static final String NAME = SyncRole.class.getName() + "$Leader"; - - private SyncRoleLeaderLogger() { - } - } - - @Test - @DisplayName("EarlyExpirationHandler:异步刷新失败 ERROR 带 cacheName 但不含 raw key") - @SuppressWarnings("unchecked") - void earlyExpirationHandler_asyncRefreshFailure_omitsRawKey() { - ListAppender captured = attach(EarlyRefresh.class); - try { - ValueOperations valueOperations = mock(ValueOperations.class); - when(valueOperations.get(any())) - .thenThrow(new IllegalStateException("redis down for key " + SECRET_KEY)); - EarlyRefresh earlyRefresh = new EarlyRefresh( - Clock.systemUTC(), - mock(ThreadPoolEarlyExpirationExecutor.class), - mock(RedisTemplate.class), - valueOperations); - - earlyRefresh.performAsyncRefresh(SECRET_KEY, "privacy-cache", null); - - assertThat(warnAndErrorText(captured)) - .doesNotContain(SECRET_KEY) - .contains("privacy-cache"); - } finally { - detach(EarlyRefresh.class, captured); - } - } -} diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java new file mode 100644 index 00000000..a152087b --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java @@ -0,0 +1,293 @@ +package io.github.davidhlp.spring.cache.redis.cache; + + + + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.CacheResult; +import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; +import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; +import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; +import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; +import io.github.davidhlp.spring.cache.redis.protection.bloom.filter.BloomIFilter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 失败上报单点 {@link FailureReport}(key-privacy contract)。 + * + *

规则本体只测一次(seam 级):一次失败 = 一条 WARN/ERROR + 一条配对 DEBUG; + * WARN/ERROR 只含 cacheName / 内容指纹 / 异常类型链,不含 raw key 与异常 message; + * 含 message 的完整堆栈只在 DEBUG。各调用点保留一条 thin 覆盖,证明「仍然上报、 + * 仍然恰好一次、仍然是同一级别、仍然输出同一指纹」,不逐站点枚举 message 文本。 + */ +@DisplayName("FailureReport seam (key-privacy contract)") +class FailureReportTest { + + /** 必须不出现的原始 key(测试专用哨兵值)。 */ + private static final String SECRET_KEY = "secret-customer-key-42"; + private static final String CACHE = "privacy-cache"; + /** seam 级测试专用 logger 名 —— 不与生产类 logger 混淆。 */ + private static final String SEAM_LOGGER = "resicache.failure-report.seam"; + + @Test + @DisplayName("error/warn:恰好一条 WARN 或 ERROR + 一条带栈 DEBUG,高层日志不含 raw key 与异常 message") + void errorAndWarn_emitSanctionedPair() { + try (Capture capture = new Capture(SEAM_LOGGER)) { + IllegalStateException failure = + new IllegalStateException("redis down for key " + SECRET_KEY); + + FailureReport.error(capture.logger, "Cache GET failed, kind=REDIS", CACHE, SECRET_KEY, failure); + FailureReport.warn(capture.logger, "Async early-expiration failed", CACHE, SECRET_KEY, failure); + + assertThat(capture.events(Level.ERROR)).hasSize(1); + assertThat(capture.events(Level.WARN)).hasSize(1); + assertThat(capture.events(Level.DEBUG)).hasSize(2); + assertThat(capture.highLevelEvents()) + .as("WARN/ERROR 不携带堆栈(异常 message 可能内嵌 raw key)") + .allMatch(event -> event.getThrowableProxy() == null); + assertThat(capture.highLevelText()) + .contains("Cache GET failed, kind=REDIS") + .contains("Async early-expiration failed") + .contains("cacheName=" + CACHE) + .contains("keyFingerprint=" + FailureReport.fingerprint(SECRET_KEY)) + .contains("cause=IllegalStateException") + .doesNotContain(SECRET_KEY) + .doesNotContain("redis down for key"); + assertThat(capture.events(Level.DEBUG)) + .as("完整栈只在 DEBUG") + .allMatch(event -> event.getThrowableProxy() != null); + } + } + + @Test + @DisplayName("无异常上下文:只输出 WARN,不伪造 DEBUG 配对") + void warnWithoutFailure_emitsSingleWarn() { + try (Capture capture = new Capture(SEAM_LOGGER)) { + FailureReport.warn(capture.logger, "Failed to acquire distributed lock within 5s", null, SECRET_KEY); + + assertThat(capture.events(Level.WARN)).hasSize(1); + assertThat(capture.events(Level.DEBUG)).isEmpty(); + assertThat(capture.highLevelText()) + .isEqualTo("Failed to acquire distributed lock within 5s: keyFingerprint=" + + FailureReport.fingerprint(SECRET_KEY)); + } + } + + @Test + @DisplayName("上下文按可用字段组装;不可识别的 key 形态不渲染") + void contextAssembly_omitsAbsentFields() { + Object opaqueKey = new Object() { + @Override + public String toString() { + return SECRET_KEY; + } + }; + try (Capture capture = new Capture(SEAM_LOGGER)) { + FailureReport.warn(capture.logger, "cache-only", CACHE, null); + FailureReport.warn(capture.logger, "key-only", null, SECRET_KEY); + FailureReport.warn(capture.logger, "no-context", null, null); + FailureReport.warn(capture.logger, "opaque-key", null, opaqueKey); + + assertThat(capture.formattedMessages(Level.WARN)).containsExactly( + "cache-only: cacheName=" + CACHE, + "key-only: keyFingerprint=" + FailureReport.fingerprint(SECRET_KEY), + "no-context", + "opaque-key"); + } + } + + @Test + @DisplayName("fingerprint:与 raw key 不同、稳定、null-safe,byte[] 按字节哈希") + void fingerprint_isStableTokenNotRawKey() { + assertThat(FailureReport.fingerprint(SECRET_KEY)) + .isNotEqualTo(SECRET_KEY) + .isEqualTo(FailureReport.fingerprint(SECRET_KEY)); + assertThat(FailureReport.fingerprint((String) null)).isEqualTo("null"); + assertThat(FailureReport.fingerprint((byte[]) null)).isEqualTo("null"); + + byte[] bytes = SECRET_KEY.getBytes(StandardCharsets.UTF_8); + assertThat(FailureReport.fingerprint(bytes)) + .as("字节形态按字节哈希(不经过 UTF-8 解码,避免非法序列碰撞)") + .isNotEqualTo(SECRET_KEY) + .isEqualTo(FailureReport.fingerprint(bytes)); + assertThat(FailureReport.fingerprint(new byte[] {(byte) 0xFF, (byte) 0xFE})) + .isNotEqualTo(FailureReport.fingerprint(new byte[] {(byte) 0xFE, (byte) 0xFF})); + } + + @Test + @DisplayName("站点 BloomSupport:三个 fail-open 失败点各恰好一条 ERROR + 一条 DEBUG,不含 raw key") + void bloomSupportFailOpen_reportsThroughSeam() { + BloomIFilter broken = new BloomIFilter() { + @Override + public void add(String cacheName, String key) { + throw new IllegalStateException("bloom add boom for key " + SECRET_KEY); + } + + @Override + public boolean mightContain(String cacheName, String key) { + throw new IllegalStateException("bloom check boom for key " + SECRET_KEY); + } + + @Override + public void clear(String cacheName) { + throw new IllegalStateException("bloom clear boom for key " + SECRET_KEY); + } + }; + try (Capture capture = new Capture(BloomSupport.class.getName())) { + BloomSupport support = new BloomSupport(broken); + + assertThat(support.mightContain(CACHE, SECRET_KEY)) + .as("fail-open 行为必须保留") + .isTrue(); + support.add(CACHE, SECRET_KEY); + support.clear(CACHE); + + assertThat(capture.events(Level.ERROR)).hasSize(3); + assertThat(capture.events(Level.DEBUG)).hasSize(3); + assertThat(capture.highLevelText()) + .contains("Bloom filter mightContain failed, defaulting to may-contain") + .contains("Bloom filter add failed") + .contains("Bloom filter clear failed") + .contains("cacheName=" + CACHE) + .doesNotContain(SECRET_KEY); + } + } + + @Test + @DisplayName("站点 RefreshRetryPolicy:重试次数不变,WARN/ERROR 只输出指纹") + void refreshRetryPolicy_exhaustedRetries_reportFingerprint() { + try (Capture capture = new Capture(RefreshRetryPolicy.class.getName())) { + RefreshRetryPolicy policy = new RefreshRetryPolicy(); + AtomicInteger attempts = new AtomicInteger(); + + assertThatThrownBy(() -> policy.executeWithRetry(SECRET_KEY, () -> { + attempts.incrementAndGet(); + throw new IllegalStateException("redis down for key " + SECRET_KEY); + })).isInstanceOf(RuntimeException.class); + + assertThat(attempts.get()).isEqualTo(RefreshRetryPolicy.MAX_RETRY_COUNT); + assertThat(capture.events(Level.ERROR)).hasSize(1); + assertThat(capture.highLevelText()) + .contains("keyFingerprint=" + FailureReport.fingerprint(SECRET_KEY)) + .doesNotContain(SECRET_KEY); + } + } + + @Test + @DisplayName("站点 SyncSupport:local-only 降级 WARN 可关联且不含 raw key") + void syncSupportLocalOnly_reportsFingerprint() { + RedisProCacheProperties properties = new RedisProCacheProperties(); + properties.getSyncLock().setLocalOnly(true); + try (Capture capture = new Capture(SyncSupport.class.getName())) { + SyncSupport support = new SyncSupport(new ArrayList<>(), properties); + + assertThat(support.executeSync(SECRET_KEY, () -> "v", 5)).isEqualTo("v"); + + assertThat(capture.events(Level.WARN)).isNotEmpty(); + assertThat(capture.highLevelText()) + .contains("protection.degraded=local-only") + .contains("keyFingerprint=" + FailureReport.fingerprint(SECRET_KEY)) + .doesNotContain(SECRET_KEY); + } + } + + @Test + @DisplayName("站点 ChainEngine:observer 失败 ERROR 只渲染异常类型链,栈留在 DEBUG") + void chainEngineObserverFailure_reportsThroughSeam() { + ChainEngine engine = new ChainEngine(); + engine.addObserver(new ChainObserver() { + @Override + public Object onChainStart(CacheContext context) { + throw new IllegalStateException("observer boom for key " + SECRET_KEY); + } + }); + CacheContext context = CacheContext.of(CacheInput.builder() + .operation(CacheOperation.GET) + .cacheName(CACHE) + .redisKey(SECRET_KEY) + .actualKey(SECRET_KEY) + .build()); + + try (Capture capture = new Capture(ChainEngine.class.getName())) { + engine.execute(List.of(new CacheHandler() { + @Override + public HandlerResult handle(CacheContext ctx) { + return HandlerResult.terminate(CacheResult.success()); + } + }), context); + + assertThat(capture.highLevelText()) + .contains("onChainStart failed", "cause=IllegalStateException") + .doesNotContain(SECRET_KEY) + .doesNotContain("observer boom"); + assertThat(capture.events(Level.DEBUG)) + .as("observer 失败的完整栈保留在 DEBUG") + .anyMatch(event -> event.getThrowableProxy() != null + && event.getFormattedMessage().contains("onChainStart")); + } + } + + /** + * 临时捕获某个 logger 全部级别的事件。测试 logger 被提升到 DEBUG —— 否则 + * 「栈保留在 DEBUG」的契约不可断言;关闭时还原原 level 并摘除 appender。 + */ + private static final class Capture implements AutoCloseable { + + private final Logger logger; + private final Level previousLevel; + private final ListAppender appender = new ListAppender<>(); + + Capture(String loggerName) { + this.logger = (Logger) LoggerFactory.getLogger(loggerName); + this.previousLevel = logger.getLevel(); + this.appender.start(); + logger.setLevel(Level.DEBUG); + logger.addAppender(appender); + } + + List events(Level level) { + return appender.list.stream().filter(event -> event.getLevel() == level).toList(); + } + + List formattedMessages(Level level) { + return events(level).stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + List highLevelEvents() { + return appender.list.stream().filter(event -> event.getLevel().isGreaterOrEqual(Level.WARN)).toList(); + } + + /** WARN/ERROR 的全部渲染文本(格式化消息 + throwable message 链)。 */ + String highLevelText() { + StringBuilder sb = new StringBuilder(); + for (ILoggingEvent event : highLevelEvents()) { + sb.append(event.getFormattedMessage()).append('\n'); + for (ch.qos.logback.classic.spi.IThrowableProxy proxy = event.getThrowableProxy(); + proxy != null; + proxy = proxy.getCause()) { + sb.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n'); + } + } + return sb.toString(); + } + + @Override + public void close() { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + } +} From d2510ae2dd78fdf159047d92f635330b2764657d Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:39:03 +0800 Subject: [PATCH 05/56] fix(cache): keep lock-release failures key-less at the seam LockStack.close has no cache key in scope (it only knows the handles), so the release-failure report keeps the context-less form it always had: one ERROR with the type chain plus the stacked DEBUG pair. --- .../io/github/davidhlp/spring/cache/redis/cache/SyncRole.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java index 3b119c57..6e816b78 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java @@ -297,7 +297,7 @@ public void close() { try { handle.close(); } catch (Exception e) { - FailureReport.error(log, "Failed to release distributed lock", null, key, e); + FailureReport.error(log, "Failed to release distributed lock", e); } } } From 34473e7b9b9367989b97d52ffe3f62f6ab3ca943 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:39:30 +0800 Subject: [PATCH 06/56] refactor(cache): concentrate TTL precedence in TtlPolicy TTL precedence used to be decided by branch order inside TtlHandler while the same 60-second default was also declared by the annotations, the Spring @CachePut adapter's builder and the handler fallback. Add package-private TtlPolicy as the single resolution point (annotation > Duration parameter > fallback default; zero/negative parameter means permanent) and let TtlHandler only apply the decision, keeping its three debug log messages and the jitter counter semantics unchanged. The annotation default and the Spring @CachePut builder default stay at 60s because both are reachable and dropping either would change behaviour; the conflict with resi-cache.default-ttl is recorded once, in TtlPolicy, as an open product decision. --- docs/ARCHITECTURE.md | 2 +- .../spring/cache/redis/cache/TtlHandler.java | 81 +++---------- .../spring/cache/redis/cache/TtlPolicy.java | 112 ++++++++++++++++++ .../redis/chain/model/CachePolicyView.java | 2 +- 4 files changed, 130 insertions(+), 67 deletions(-) create mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7516c36e..51b7ec6a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,7 +55,7 @@ classes can move or disappear without becoming a compatibility promise. | 100 | `BloomFilterHandler` | membership gate / penetration protection | | 200 | `SyncLockHandler` | distributed or explicit local-only synchronization | | 250 | `EarlyExpirationHandler` | hot-key refresh decision and scheduling | -| 300 | `TtlHandler` | base TTL and jitter calculation | +| 300 | `TtlHandler` | TTL decision application (precedence and jitter resolve in `TtlPolicy`) | | 400 | `NullValueHandler` | negative-result encoding | | 500 | `ActualCacheHandler` | actual cache operation | diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandler.java index cb614c4e..68e5f42c 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandler.java @@ -12,8 +12,6 @@ import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; import io.github.davidhlp.spring.cache.redis.chain.model.TtlDecision; -import java.time.Duration; -import java.util.concurrent.ThreadLocalRandom; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -22,9 +20,8 @@ * *

职责: *

    - *
  1. 计算最终的 TTL 值
  2. - *
  3. 支持从配置或参数获取 TTL
  4. - *
  5. 支持随机化 TTL(防止缓存雪崩)
  6. + *
  7. 把 {@link TtlPolicy} 解析出的 TTL 决策写入上下文(优先级与默认值归 TtlPolicy 所有)
  8. + *
  9. 按决策来源输出 debug 日志、自增 TTL 抖动计数
  10. *
* *

输出: @@ -38,7 +35,6 @@ @Component @HandlerPriority(HandlerOrder.TTL) class TtlHandler extends AbstractCacheHandler { - private static final long DEFAULT_TTL = 60; /** * 语义 counter 元数据声明:TTL jitter 应用事件计数(防雪崩:randomTtl=true @@ -60,81 +56,36 @@ protected boolean shouldHandle(CacheContext context) { @Override protected HandlerResult doHandle(CacheContext context, ChainContinuation next) { - calculateTtl(context); - // 继续执行后续 Handler - return HandlerResult.continueChain(); - } - - /** - * 计算 TTL。 - */ - private void calculateTtl(CacheContext context) { - Duration ttl = context.getTtl(); - if (ttl == null) { - ttl = Duration.ofSeconds(DEFAULT_TTL); - } - - // 优先使用配置中的 TTL(经稳定 CachePolicyView 读取,不依赖内部 operation) CachePolicyView policy = context.policy(); - if (policy.ttl() > 0) { - long finalTtl = calculateFinalTtl(policy.ttl(), policy.randomTtl(), policy.variance()); - - context.setTtlDecision(TtlDecision.applied(finalTtl)); + TtlPolicy.Resolution resolution = TtlPolicy.resolve(context.getTtl(), policy); + context.setTtlDecision(resolution.decision()); - // TTL jitter 应用计数(randomTtl=true 时 variance 展开) - if (policy.randomTtl()) { - safeIncrementSemantic(); - } + // TTL jitter 应用计数(注解路径 + randomTtl=true 时) + if (resolution.jitterRequested()) { + safeIncrementSemantic(); + } - log.debug( + switch (resolution.source()) { + case ANNOTATION -> log.debug( "Using context TTL configuration: cacheName={}, key={}, baseTtl={}s, finalTtl={}s, randomTtl={}, variance={}", context.getCacheName(), context.getRedisKey(), policy.ttl(), - finalTtl, + resolution.decision().finalTtl(), policy.randomTtl(), policy.variance()); - } else if (shouldApply(ttl)) { - // 使用参数中的 TTL - long finalTtl = ttl.getSeconds(); - context.setTtlDecision(TtlDecision.applied(finalTtl)); - - log.debug( + case PARAMETER -> log.debug( "Using parameter TTL: cacheName={}, key={}, ttl={}s", context.getCacheName(), context.getRedisKey(), - finalTtl); - } else { - // 不应用 TTL(永久缓存) - context.setTtlDecision(TtlDecision.skipped()); - - log.debug( + resolution.decision().finalTtl()); + case NONE -> log.debug( "No TTL applied: cacheName={}, key={}", context.getCacheName(), context.getRedisKey()); } - } - - /** ttl 非空、非零、非负则应用。 */ - private boolean shouldApply(Duration ttl) { - return ttl != null && !ttl.isZero() && !ttl.isNegative(); - } - - /** 计算最终 TTL; randomTtl=true 时按 variance 抖动以防雪崩。 */ - long calculateFinalTtl(Long baseTtl, boolean randomTtl, float variance) { - if (baseTtl == null || baseTtl <= 0) { - return -1; - } - if (!randomTtl || variance <= 0) { - return baseTtl; - } - - float boundedVariance = Math.min(1.0f, Math.max(0.0f, variance)); - double randomFactor = ThreadLocalRandom.current().nextGaussian(); - randomFactor = Math.max(-3.0, Math.min(3.0, randomFactor)); - long offset = (long) (baseTtl * boundedVariance * randomFactor / 3.0); - long result = baseTtl + offset; - return Math.max(1, Math.min(result, baseTtl * 2)); + // 继续执行后续 Handler + return HandlerResult.continueChain(); } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java new file mode 100644 index 00000000..7605b52d --- /dev/null +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java @@ -0,0 +1,112 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; +import io.github.davidhlp.spring.cache.redis.chain.model.TtlDecision; +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; + +/** + * TTL 优先级的唯一实现 —— 把两个真实输入解析为 {@link TtlDecision}。 + * + *

输入与优先级(自上而下,第一条命中即胜出;与历史行为逐条一致): + *

    + *
  1. 注解:方法级 {@link CachePolicyView#ttl()} 秒数 > 0 时使用注解秒数, + * 并按 {@code randomTtl}/{@code variance} 抖动;
  2. + *
  3. 参数:{@link Duration} 非空、非零、非负时使用其秒数。写路径的这个 Duration + * 由 Spring Data Redis 依 cache 级配置算出并传入({@code resi-cache.default-ttl}, + * 默认 30 分钟;{@code caches.*.ttl} 可覆盖),因此"配置的默认 TTL"只在方法级 + * TTL 为 0 或不存在时才生效;
  4. + *
  5. 参数为 {@code null}(无 TTL 上下文)时使用 {@link #DEFAULT_TTL_SECONDS};
  6. + *
  7. 参数为零或负 → 永久缓存({@link TtlDecision#skipped()})。
  8. + *
+ * + *

已知分歧(产品决策待定,只在此处陈述,勿在第二处重复):注解声明侧的 60 秒 + * ({@code @RedisCacheable}/{@code @RedisCachePut} 的 {@code ttl} 属性默认值,以及 Spring + * {@code @CachePut} 适配路径的 {@link RedisCachePutOperation} builder 默认值)会覆盖 cache 级 + * {@code resi-cache.default-ttl}(默认 30 分钟)。评审判定"60 秒还是 30 分钟应胜出"属于产品 + * 问题且尚无裁决,故两条默认值均按现状保留。 + */ +final class TtlPolicy { + + /** + * 注解与参数都不提供 TTL 时的兜底秒数。 + * + *

与 {@code @RedisCacheable}/{@code @RedisCachePut} 的 {@code ttl} 属性默认值相等 —— + * 该相等关系使"属性未设置"与"无参数"两条路径得出同一结果,单方面改动任一侧即改变行为。 + */ + static final long DEFAULT_TTL_SECONDS = 60; + + /** TTL 来源 —— 与写链的三条 debug 日志一一对应。 */ + enum Source { + /** 方法级注解策略({@link CachePolicyView#ttl()} > 0)。 */ + ANNOTATION, + /** {@link Duration} 参数(含参数为 {@code null} 时的兜底秒数)。 */ + PARAMETER, + /** 不应用 TTL(永久缓存)。 */ + NONE + } + + /** + * 解析结果。 + * + * @param decision 写入 {@code CacheContext} 的 TTL 决策 + * @param source 胜出的来源 + * @param jitterRequested 来源为注解且 {@code randomTtl=true};保持既有 + * {@code resicache.handler.ttl.jittered} 计数语义(与 variance + * 是否真的展开抖动无关) + */ + record Resolution(TtlDecision decision, Source source, boolean jitterRequested) { + } + + private TtlPolicy() { + } + + /** + * 解析 TTL。 + * + * @param parameterTtl 调用方 TTL(Duration);可为 {@code null}(无 TTL 上下文)、零或负(永久语义) + * @param policy 方法级注解策略视图;{@link CachePolicyView#NONE} 表示无方法级声明 + * @return 决策与来源 + */ + static Resolution resolve(Duration parameterTtl, CachePolicyView policy) { + long annotationTtlSeconds = policy.ttl(); + if (annotationTtlSeconds > 0) { + long finalTtl = + calculateFinalTtl(annotationTtlSeconds, policy.randomTtl(), policy.variance()); + return new Resolution( + TtlDecision.applied(finalTtl), Source.ANNOTATION, policy.randomTtl()); + } + if (applicable(parameterTtl)) { + return new Resolution( + TtlDecision.applied(parameterTtl.getSeconds()), Source.PARAMETER, false); + } + if (parameterTtl == null) { + return new Resolution( + TtlDecision.applied(DEFAULT_TTL_SECONDS), Source.PARAMETER, false); + } + return new Resolution(TtlDecision.skipped(), Source.NONE, false); + } + + /** {@link Duration} 非空、非零、非负则可作为参数 TTL 应用。 */ + private static boolean applicable(Duration parameterTtl) { + return parameterTtl != null && !parameterTtl.isZero() && !parameterTtl.isNegative(); + } + + /** 计算最终 TTL;randomTtl=true 时按 variance 抖动以防雪崩(仅注解路径调用)。 */ + static long calculateFinalTtl(Long baseTtl, boolean randomTtl, float variance) { + if (baseTtl == null || baseTtl <= 0) { + return -1; + } + if (!randomTtl || variance <= 0) { + return baseTtl; + } + + float boundedVariance = Math.min(1.0f, Math.max(0.0f, variance)); + double randomFactor = ThreadLocalRandom.current().nextGaussian(); + randomFactor = Math.max(-3.0, Math.min(3.0, randomFactor)); + + long offset = (long) (baseTtl * boundedVariance * randomFactor / 3.0); + long result = baseTtl + offset; + return Math.max(1, Math.min(result, baseTtl * 2)); + } +} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/chain/model/CachePolicyView.java b/src/main/java/io/github/davidhlp/spring/cache/redis/chain/model/CachePolicyView.java index 39bf0802..8cfee104 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/chain/model/CachePolicyView.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/chain/model/CachePolicyView.java @@ -7,7 +7,7 @@ * {@code RedisCacheableOperation} 的泄漏。稳定扩展 {@link io.github.davidhlp.spring.cache.redis.chain.CacheHandler} * 只应读取本视图承载的有限策略字段,不依赖内部 operation 类型。 * - * @param ttl 方法级 TTL 秒数;{@code 0} = 未配置(走参数/默认 TTL) + * @param ttl 方法级 TTL 秒数(注解 {@code ttl} 属性原值,不是 Duration);{@code 0} = 不采用方法级 TTL(回退调用方参数或兜底默认) * @param randomTtl 是否启用 TTL 随机化(防雪崩) * @param variance TTL 随机化范围 * @param useBloomFilter 是否启用布隆穿透防护 From 6c6bbd4ba5703b062436e0265735e8703ce4d07b Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:40:06 +0800 Subject: [PATCH 07/56] test(cache): pin TTL resolution per input combination TtlPolicyTest pins every reachable input combination without a handler chain: attribute set / unset (the annotation's own 60s default, proven through projector -> operation -> policy), explicit ttl=0 with and without a Duration parameter, zero/negative parameter, no declaration at all, and the configured 30m default. The jitter bounds tests move with the method they cover, and TtlHandlerTest keeps the handler-level application and counter cases (including the parameter path, which must not count as jittered). --- .../cache/redis/cache/TtlHandlerTest.java | 70 +++--- .../cache/redis/cache/TtlPolicyTest.java | 202 ++++++++++++++++++ 2 files changed, 230 insertions(+), 42 deletions(-) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java index dd9134b6..31865d5e 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java @@ -15,7 +15,8 @@ /** * TtlHandler 单元测试。 * - *

TTL 默认值、配置优先级、永久缓存哨兵和抖动均由处理器直接拥有。 + *

TTL 优先级、默认值与抖动由 {@link TtlPolicy} 拥有(见 TtlPolicyTest); + * 本测试覆盖处理器对决策的应用与 jitter 计数。 */ @DisplayName("TtlHandler Tests") class TtlHandlerTest { @@ -42,7 +43,7 @@ private CacheContext createContext(CacheOperation operation, Duration ttl, return new CacheContext(input); } - private RedisCacheableOperation configuredOperation(long ttl, boolean randomTtl, float variance) { + private RedisCacheableOperation annotatedOperation(long ttl, boolean randomTtl, float variance) { return RedisCacheableOperation.builder() .name("test-cache") .cacheNames("test-cache") @@ -64,7 +65,7 @@ void randomTtlTrue_incrementsJitteredCounter() { h.attachMeterRegistry(registry); h.handle(createContext(CacheOperation.PUT, Duration.ofSeconds(60), - configuredOperation(60, true, 0.2f))); + annotatedOperation(60, true, 0.2f))); assertThat(registry.get("resicache.handler.ttl.jittered").counter().count()) .isEqualTo(1.0); @@ -78,7 +79,21 @@ void randomTtlFalse_doesNotIncrement() { h.attachMeterRegistry(registry); h.handle(createContext(CacheOperation.PUT, Duration.ofSeconds(60), - configuredOperation(60, false, 0.2f))); + annotatedOperation(60, false, 0.2f))); + + assertThat(registry.get("resicache.handler.ttl.jittered").counter().count()) + .isEqualTo(0.0); + } + + @Test + @DisplayName("randomTtl=true on the parameter path does not increment the jitter counter") + void randomTtlTrue_parameterPath_doesNotIncrement() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + TtlHandler h = new TtlHandler(); + h.attachMeterRegistry(registry); + + h.handle(createContext(CacheOperation.PUT, Duration.ofSeconds(60), + annotatedOperation(0, true, 0.2f))); assertThat(registry.get("resicache.handler.ttl.jittered").counter().count()) .isEqualTo(0.0); @@ -117,13 +132,13 @@ void shouldHandle_cleanOperation_returnsFalse() { } @Nested - @DisplayName("handler-owned TTL decisions") + @DisplayName("handler-applied TTL decisions") class TtlDecisionTests { @Test - void configuredTtl_takesPrecedenceOverParameterTtl() { + void annotationTtl_takesPrecedenceOverParameterTtl() { CacheContext context = createContext(CacheOperation.PUT, Duration.ofSeconds(30), - configuredOperation(120, false, 0.2f)); + annotatedOperation(120, false, 0.2f)); handler.doHandle(context, CacheResult::success); @@ -132,9 +147,9 @@ void configuredTtl_takesPrecedenceOverParameterTtl() { } @Test - void parameterTtl_isUsedWhenConfiguredTtlIsAbsent() { + void parameterTtl_isUsedWhenAnnotationTtlIsZero() { CacheContext context = createContext(CacheOperation.PUT, Duration.ofSeconds(30), - configuredOperation(0, false, 0.2f)); + annotatedOperation(0, false, 0.2f)); handler.doHandle(context, CacheResult::success); @@ -145,7 +160,7 @@ void parameterTtl_isUsedWhenConfiguredTtlIsAbsent() { @Test void zeroParameterTtl_skipsTtl() { CacheContext context = createContext(CacheOperation.PUT, Duration.ZERO, - configuredOperation(0, false, 0.2f)); + annotatedOperation(0, false, 0.2f)); handler.doHandle(context, CacheResult::success); @@ -156,7 +171,7 @@ void zeroParameterTtl_skipsTtl() { @Test void negativeParameterTtl_mapsToPermanentCacheSentinel() { CacheContext context = createContext(CacheOperation.PUT, Duration.ofSeconds(-1), - configuredOperation(0, false, 0.2f)); + annotatedOperation(0, false, 0.2f)); handler.doHandle(context, CacheResult::success); @@ -167,48 +182,19 @@ void negativeParameterTtl_mapsToPermanentCacheSentinel() { @Test void missingTtl_usesDefaultTtl() { CacheContext context = createContext(CacheOperation.PUT, null, - configuredOperation(0, false, 0.2f)); + annotatedOperation(0, false, 0.2f)); handler.doHandle(context, CacheResult::success); assertThat(context.getTtlDecision().shouldApplyTtl()).isTrue(); assertThat(context.getTtlDecision().finalTtl()).isEqualTo(60L); } - - @Test - void nullZeroAndNegativeBaseTtl_mapToPermanentSentinel() { - assertThat(handler.calculateFinalTtl(null, false, 0.2f)).isEqualTo(-1L); - assertThat(handler.calculateFinalTtl(0L, false, 0.2f)).isEqualTo(-1L); - assertThat(handler.calculateFinalTtl(-1L, false, 0.2f)).isEqualTo(-1L); - } - - @Test - void nonPositiveVariance_doesNotJitterBaseTtl() { - assertThat(handler.calculateFinalTtl(120L, true, 0.0f)).isEqualTo(120L); - assertThat(handler.calculateFinalTtl(120L, true, -0.1f)).isEqualTo(120L); - } - - @Test - void varianceAboveOne_isClampedToSafeOutputBounds() { - for (int i = 0; i < 128; i++) { - assertThat(handler.calculateFinalTtl(120L, true, 2.0f)) - .isBetween(1L, 240L); - } - } - - @Test - void jitteredConfiguredTtl_staysWithinBoundedVariance() { - for (int i = 0; i < 128; i++) { - assertThat(handler.calculateFinalTtl(120L, true, 0.1f)) - .isBetween(108L, 132L); - } - } } @Test void doHandle_alwaysContinuesChain() { CacheContext context = createContext(CacheOperation.PUT, null, - configuredOperation(120, false, 0.2f)); + annotatedOperation(120, false, 0.2f)); HandlerResult result = handler.doHandle(context, CacheResult::success); diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java new file mode 100644 index 00000000..8f93d59d --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java @@ -0,0 +1,202 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; +import io.github.davidhlp.spring.cache.redis.protection.refresh.EarlyExpirationMode; +import java.lang.reflect.Method; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * TtlPolicy 单元测试 —— TTL 优先级(注解 / Duration 参数 / 兜底默认)与抖动,均不经 handler 链。 + * + *

行为基线:每条用例断言的是 TtlPolicy 引入前后逐字保留的既有结果。 + */ +@DisplayName("TtlPolicy Tests") +class TtlPolicyTest { + + /** Spring Data Redis 写路径传入的 cache 级 TTL(resi-cache.default-ttl 默认 30m)。 */ + private static final Duration CONFIGURED_DEFAULT = Duration.ofMinutes(30); + + private static CachePolicyView annotationPolicy(long ttlSeconds) { + return annotationPolicy(ttlSeconds, false, 0.2F); + } + + private static CachePolicyView annotationPolicy( + long ttlSeconds, boolean randomTtl, float variance) { + return new CachePolicyView( + ttlSeconds, randomTtl, variance, false, false, 0, false, false, 0.3, + EarlyExpirationMode.SYNC); + } + + @Nested + @DisplayName("precedence per input combination") + class PrecedenceTests { + + @Test + @DisplayName("attribute set (120s) wins over the 30m parameter") + void annotationSet_winsOverParameter() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(120)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); + assertThat(resolution.decision().shouldApplyTtl()).isTrue(); + assertThat(resolution.decision().finalTtl()).isEqualTo(120L); + } + + @Test + @DisplayName("attribute unset (annotation default 60s) wins over the 30m parameter") + void annotationUnsetDefault_winsOverParameter() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(60)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); + assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + } + + @Test + @DisplayName("attribute unset without any parameter still resolves to 60s") + void annotationUnsetDefault_withoutParameter_resolvesTo60s() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, annotationPolicy(60)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); + assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + } + + @Test + @DisplayName("attribute explicitly 0 uses the Duration parameter") + void annotationZero_usesParameter() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(0)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(resolution.decision().finalTtl()).isEqualTo(1800L); + } + + @Test + @DisplayName("attribute explicitly 0 without a parameter falls back to 60s") + void annotationZero_withoutParameter_fallsBackTo60s() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, annotationPolicy(0)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + } + + @Test + @DisplayName("zero parameter means permanent") + void zeroParameter_meansPermanent() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(Duration.ZERO, annotationPolicy(0)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.NONE); + assertThat(resolution.decision().shouldApplyTtl()).isFalse(); + assertThat(resolution.decision().finalTtl()).isEqualTo(-1L); + } + + @Test + @DisplayName("negative parameter means permanent") + void negativeParameter_meansPermanent() { + TtlPolicy.Resolution resolution = + TtlPolicy.resolve(Duration.ofSeconds(-1), annotationPolicy(0)); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.NONE); + assertThat(resolution.decision().shouldApplyTtl()).isFalse(); + } + + @Test + @DisplayName("no method-level policy uses the configured default only") + void noAnnotation_usesConfiguredDefault() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, CachePolicyView.NONE); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(resolution.decision().finalTtl()).isEqualTo(1800L); + } + + @Test + @DisplayName("no method-level policy and no parameter falls back to 60s") + void noAnnotation_withoutParameter_fallsBackTo60s() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, CachePolicyView.NONE); + + assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + } + + @Test + @DisplayName("randomTtl on the annotation path jitters only the annotation TTL") + void randomTtl_doesNotJitterParameterPath() { + TtlPolicy.Resolution annotation = + TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(120, true, 0.5F)); + TtlPolicy.Resolution parameter = + TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(0, true, 0.5F)); + + assertThat(annotation.jitterRequested()).isTrue(); + assertThat(annotation.decision().finalTtl()).isBetween(60L, 240L); + assertThat(parameter.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(parameter.jitterRequested()).isFalse(); + assertThat(parameter.decision().finalTtl()).isEqualTo(1800L); + } + } + + @Nested + @DisplayName("annotation attribute default (unset) link") + class AnnotationDefaultLinkTests { + + @RedisCacheable(cacheNames = "ttl-policy-sample") + private String annotatedWithDefaults(String id) { + return id; + } + + @Test + @DisplayName("unset ttl attribute projects to 60s and wins over the 30m configured default") + void unsetTtlAttribute_resolvesTo60s() throws Exception { + Method method = AnnotationDefaultLinkTests.class + .getDeclaredMethod("annotatedWithDefaults", String.class); + RedisCacheable annotation = method.getAnnotation(RedisCacheable.class); + RedisCacheAttributes attributes = new RedisCacheAttributesProjector().from(annotation); + RedisCacheableOperation operation = + RedisCacheableOperation.fromAttributes(method, annotation.key(), attributes); + + CachePolicyView policy = new CacheInput( + CacheOperation.PUT, "ttl-policy-sample", "k", "k", null, null, null, operation) + .policy(); + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, policy); + + assertThat(policy.ttl()).isEqualTo(60L); + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); + assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + } + } + + @Nested + @DisplayName("calculateFinalTtl") + class JitterTests { + + @Test + void nullZeroAndNegativeBaseTtl_mapToPermanentSentinel() { + assertThat(TtlPolicy.calculateFinalTtl(null, false, 0.2f)).isEqualTo(-1L); + assertThat(TtlPolicy.calculateFinalTtl(0L, false, 0.2f)).isEqualTo(-1L); + assertThat(TtlPolicy.calculateFinalTtl(-1L, false, 0.2f)).isEqualTo(-1L); + } + + @Test + void nonPositiveVariance_doesNotJitterBaseTtl() { + assertThat(TtlPolicy.calculateFinalTtl(120L, true, 0.0f)).isEqualTo(120L); + assertThat(TtlPolicy.calculateFinalTtl(120L, true, -0.1f)).isEqualTo(120L); + } + + @Test + void varianceAboveOne_isClampedToSafeOutputBounds() { + for (int i = 0; i < 128; i++) { + assertThat(TtlPolicy.calculateFinalTtl(120L, true, 2.0f)) + .isBetween(1L, 240L); + } + } + + @Test + void jitteredBaseTtl_staysWithinBoundedVariance() { + for (int i = 0; i < 128; i++) { + assertThat(TtlPolicy.calculateFinalTtl(120L, true, 0.1f)) + .isBetween(108L, 132L); + } + } + } +} From 0206b58c89e28b6699f68ef5ac91aa7db3492b48 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:40:20 +0800 Subject: [PATCH 08/56] fix(test): stream the multi-valued ComponentScan.Filter.pattern() --- .../redis/cache/RedisProCacheConfigurationContractTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index b901d681..621845e0 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -153,7 +153,7 @@ void entry_componentScan_usesNoOwnershipNamePattern() { ComponentScan scan = RedisCacheAutoConfiguration.class.getAnnotation(ComponentScan.class); assertThat(java.util.Arrays.stream(scan.excludeFilters()) .filter(filter -> filter.type() == FilterType.REGEX) - .map(ComponentScan.Filter::pattern) + .flatMap(filter -> java.util.Arrays.stream(filter.pattern())) .toList()) .containsExactly(".*Test.*"); } From a93336fa2a908bbbec674bda21fa60fe5948f524 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:41:01 +0800 Subject: [PATCH 09/56] test(cache): attach the local-only site check to the role logger The local-only degradation WARN is emitted through the elected role's logger (Leader carries the key context), not SyncSupport's, and the no-throwable warn is asserted on rendered messages instead of the concatenated text. --- .../spring/cache/redis/cache/FailureReportTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java index a152087b..2edec10a 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureReportTest.java @@ -80,8 +80,8 @@ void warnWithoutFailure_emitsSingleWarn() { assertThat(capture.events(Level.WARN)).hasSize(1); assertThat(capture.events(Level.DEBUG)).isEmpty(); - assertThat(capture.highLevelText()) - .isEqualTo("Failed to acquire distributed lock within 5s: keyFingerprint=" + assertThat(capture.formattedMessages(Level.WARN)) + .containsExactly("Failed to acquire distributed lock within 5s: keyFingerprint=" + FailureReport.fingerprint(SECRET_KEY)); } } @@ -191,7 +191,8 @@ void refreshRetryPolicy_exhaustedRetries_reportFingerprint() { void syncSupportLocalOnly_reportsFingerprint() { RedisProCacheProperties properties = new RedisProCacheProperties(); properties.getSyncLock().setLocalOnly(true); - try (Capture capture = new Capture(SyncSupport.class.getName())) { + // 降级 WARN 走角色自己的 logger(Leader 持 key 上下文),不是 SyncSupport 的 logger。 + try (Capture capture = new Capture(SyncRole.class.getName() + "$Leader")) { SyncSupport support = new SyncSupport(new ArrayList<>(), properties); assertThat(support.executeSync(SECRET_KEY, () -> "v", 5)).isEqualTo("v"); From e854a8a0bcf2bddfef2bd50e89f92495702c4b05 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:43:53 +0800 Subject: [PATCH 10/56] refactor(cache): derive AOP and policy operations from one projection AnnotationParser built every RedisCache annotation twice: RedisCacheAttributes for the policy operation and an independent BuilderPopulator field list for the Spring-facing operation, so the two graphs could disagree on any shared field (they did: the AOP face preferred `value`, the projection preferred `cacheNames`) and one new field had to be threaded through both. Both faces now derive from one RedisCacheAttributes instance per annotation, and the shared AOP field set is declared once (applyToSpringCommonFields) beside the policy COMMON_SINKS mapping, so the values cannot diverge. The snapshot indexes policy operations by kind + cacheName when it is built, so RedisCacheRegister.get no longer depends on a backwards scan whose overwrite semantics lived in a comment; "last declaration wins" is stated and implemented once in ParsedAnnotations.PolicyIndex. Deleted the dead injector seam: RedisCacheAttributesProjector and SpringCacheableAdapter carried @Component with no injectors, and the two-argument AnnotationParser constructor that accepted them had no caller. Javadoc that understated what a new field costs (projector, RedisCacheAttributeSink, BuilderPopulator) now records the measured inventory. AnnotationAopBehaviorMatrixTest pins both faces coming from one projection, the shared value/cacheNames resolution, and last-wins for repeated declarations. --- docs/ARCHITECTURE.md | 8 +- .../cache/redis/cache/AnnotationParser.java | 305 ++++++++---------- .../cache/redis/cache/BuilderPopulator.java | 27 +- .../redis/cache/RedisCacheAttributeSink.java | 5 +- .../redis/cache/RedisCacheAttributes.java | 71 ++++ .../cache/RedisCacheAttributesProjector.java | 32 +- .../cache/redis/cache/RedisCacheRegister.java | 20 +- .../redis/cache/SpringCacheableAdapter.java | 4 +- .../AnnotationAopBehaviorMatrixTest.java | 53 +++ 9 files changed, 321 insertions(+), 204 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7516c36e..bb64731a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -77,8 +77,12 @@ The annotation path is intentionally split into two views: declaration; read-through write-back remains governed by the read side. The split is required by the Spring operation source and the chain-side policy -resolver. It is not permission to reintroduce per-invocation parsing or to -collapse the two operation representations without a new contract decision. +resolver. Both views are projected from one `RedisCacheAttributes` instance per +annotation, and the snapshot carries a `kind + cacheName` index built at +registration time, so the two views cannot disagree and policy lookup does not +depend on declaration order. The split is not permission to reintroduce +per-invocation parsing or to collapse the two operation representations without +a new contract decision. Class-level operation discovery and method-level policy application retain the current documented behavior in `COMPATIBILITY.md`. diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationParser.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationParser.java index 7b115c05..8874ab5e 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationParser.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationParser.java @@ -3,20 +3,23 @@ - - import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheEvict; import io.github.davidhlp.spring.cache.redis.annotation.RedisCachePut; import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; import io.github.davidhlp.spring.cache.redis.annotation.RedisCaching; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.interceptor.CacheEvictOperation; import org.springframework.cache.interceptor.CacheOperation; import org.springframework.cache.interceptor.CachePutOperation; import org.springframework.cache.interceptor.CacheableOperation; +import org.springframework.lang.Nullable; /** * ResiCache 注解解析器(职责1). @@ -25,16 +28,16 @@ * 与复合注解 {@code @RedisCaching} 的解析与展开逻辑。纯函数、无状态、无 Spring 继承负担, * 可被 {@link RedisCacheOperationSource} 之外的代码(测试)直接调用。 * - *

设计要点:{@code parseRedisCacheable} 刻意构建 Spring 标准 + *

设计要点:一个注解只投影一次 —— {@link RedisCacheAttributesProjector} 产出唯一的 + * {@link RedisCacheAttributes},AOP operation 与 policy operation 都从这份投影派生 + * ({@link RedisCacheAttributes#applyTo(CacheableOperation.Builder)} / + * {@code RedisCacheableOperation#fromAttributes})。AOP 面刻意使用 Spring 原生 * {@link org.springframework.cache.interceptor.CacheableOperation}(而非 ResiCache 的 * RedisCacheableOperation),确保 getClass() 返回 CacheableOperation.class —— 这样 * CacheAspectSupport 的 CacheOperationContexts 能正确按类型索引(可缓存/可放入/可清除三桶)。 * *

Method/Class 的注解读取与名称提取统一委派给 * {@link AnnotationTargets#findMerged} 与 {@link AnnotationTargets#extractTargetName}。 - * - *

3 个 parse 方法的字段填充(text + special)统一委派给 - * {@link BuilderPopulator#populate},新增 ResiCache 字段仅需追加 1 个 populate spec 行。 */ @Slf4j class AnnotationParser { @@ -47,15 +50,10 @@ class AnnotationParser { this.springCacheableAdapter = new SpringCacheableAdapter(); } - AnnotationParser( - RedisCacheAttributesProjector projector, - SpringCacheableAdapter springCacheableAdapter) { - this.projector = projector; - this.springCacheableAdapter = springCacheableAdapter; - } - /** * 单次解析目标元素,同时产出 Spring operation 与 annotation chain policy operation。 + * + *

每个注解投影一次,两副面孔共享同一份 {@link RedisCacheAttributes}。 */ ParsedAnnotations parse(final Object target) { final List operations = new ArrayList<>(); @@ -65,225 +63,200 @@ ParsedAnnotations parse(final Object target) { final RedisCacheable cacheable = AnnotationTargets.findMerged(target, RedisCacheable.class); if (cacheable != null) { - operations.add(parseRedisCacheable(cacheable, target)); - addPolicy(policyOperations, cacheable, target); + addCacheable(operations, policyOperations, cacheable, target); } else { final Cacheable springCacheable = AnnotationTargets.findMerged(target, Cacheable.class); if (springCacheable != null) { - addPolicy(policyOperations, springCacheable, target); + addSpringCacheablePolicy(policyOperations, springCacheable, target); } } final RedisCacheEvict cacheEvict = AnnotationTargets.findMerged(target, RedisCacheEvict.class); if (cacheEvict != null) { - operations.add(parseRedisCacheEvict(cacheEvict, target)); - addPolicy(policyOperations, cacheEvict, target); + addEvict(operations, policyOperations, cacheEvict, target); } final RedisCachePut cachePut = AnnotationTargets.findMerged(target, RedisCachePut.class); if (cachePut != null) { - operations.add(parseRedisCachePut(cachePut, target)); - addPolicy(policyOperations, cachePut, target); + addPut(operations, policyOperations, cachePut, target); } final RedisCaching caching = AnnotationTargets.findMerged(target, RedisCaching.class); if (caching != null) { for (final RedisCacheable annotation : caching.redisCacheable()) { - operations.add(parseRedisCacheable(annotation, target)); - addPolicy(policyOperations, annotation, target); + addCacheable(operations, policyOperations, annotation, target); } for (final RedisCacheEvict annotation : caching.redisCacheEvict()) { - operations.add(parseRedisCacheEvict(annotation, target)); - addPolicy(policyOperations, annotation, target); + addEvict(operations, policyOperations, annotation, target); } for (final RedisCachePut annotation : caching.redisCachePut()) { - operations.add(parseRedisCachePut(annotation, target)); - addPolicy(policyOperations, annotation, target); + addPut(operations, policyOperations, annotation, target); } } return new ParsedAnnotations(operations, policyOperations); } - - private void addPolicy( - List policies, RedisCacheable annotation, Object target) { - if (target instanceof java.lang.reflect.Method method) { - policies.add(RedisCacheableOperation.fromAttributes( - method, annotation.key(), projector.from(annotation))); - } - } - - private void addPolicy( - List policies, RedisCachePut annotation, Object target) { - if (target instanceof java.lang.reflect.Method method) { - policies.add(RedisCachePutOperation.fromAttributes( - method, annotation.key(), projector.from(annotation))); - } - } - - private void addPolicy( - List policies, RedisCacheEvict annotation, Object target) { - if (target instanceof java.lang.reflect.Method method) { - policies.add(RedisCacheEvictOperation.fromAttributes( - method, annotation.key(), projector.from(annotation))); - } - } - - private void addPolicy( - List policies, Cacheable annotation, Object target) { - if (target instanceof java.lang.reflect.Method method) { - policies.add(springCacheableAdapter.create(method, annotation, annotation.key())); - } - } - - record ParsedAnnotations( - List operations, - List policyOperations) { - - ParsedAnnotations { - operations = List.copyOf(operations); - policyOperations = List.copyOf(policyOperations); - } - } - /** - * 解析 @RedisCacheable 注解. + * {@code @RedisCacheable}:一份投影 → AOP operation + policy operation。 * - * @param ann 注解实例 - * @param target 方法或类对象 - * @return 缓存操作 + *

方法级目标才有 policy operation(类级声明只参与 Spring 侧发现)。 */ - private CacheOperation parseRedisCacheable( - final RedisCacheable ann, final Object target) { - final String name = AnnotationTargets.extractTargetName(target); + private void addCacheable( + final List operations, + final List policyOperations, + final RedisCacheable annotation, + final Object target) { log.trace("Parsing @RedisCacheable annotation for target: {}", target); + final RedisCacheAttributes attributes = projector.from(annotation); - // 使用 Spring 标准的 CacheableOperation.Builder,确保 getClass() 返回 CacheableOperation.class - // 这样 CacheAspectSupport 的 CacheOperationContexts 能正确按类型索引 + // AOP 面走 Spring 原生 Builder(见类注释的 getClass() 约束) final CacheableOperation.Builder builder = new CacheableOperation.Builder(); - builder.setName(name); - builder.setCacheNames( - ann.value().length > 0 ? ann.value() : ann.cacheNames()); - - // 6 文本字段 + 1 special 字段填充委派到 BuilderPopulator.populate。 - // setter 引用形态兼容 Spring 标准 Builder(setX 命名)与 Lombok Builder(x 命名)。 - BuilderPopulator.populate(builder, ann, - List.of( - BuilderPopulator.TextField.textField( - RedisCacheable::key, CacheableOperation.Builder::setKey), - BuilderPopulator.TextField.textField( - RedisCacheable::condition, CacheableOperation.Builder::setCondition), - BuilderPopulator.TextField.textField( - RedisCacheable::unless, CacheableOperation.Builder::setUnless), - BuilderPopulator.TextField.textField( - RedisCacheable::keyGenerator, CacheableOperation.Builder::setKeyGenerator), - BuilderPopulator.TextField.textField( - RedisCacheable::cacheManager, CacheableOperation.Builder::setCacheManager), - BuilderPopulator.TextField.textField( - RedisCacheable::cacheResolver, CacheableOperation.Builder::setCacheResolver) - ), - List.of((b, a) -> b.setSync(a.sync()))); - + builder.setName(AnnotationTargets.extractTargetName(target)); + attributes.applyTo(builder); final CacheableOperation operation = builder.build(); log.debug("Built CacheableOperation: {}", operation); - return operation; + operations.add(operation); + + if (target instanceof Method method) { + policyOperations.add(RedisCacheableOperation.fromAttributes( + method, annotation.key(), attributes)); + } } /** - * 解析 @RedisCacheEvict 注解. - * - * @param ann 注解实例 - * @param target 方法或类对象 - * @return 缓存操作 + * {@code @RedisCacheEvict}:一份投影 → AOP operation + policy operation。 */ - private CacheOperation parseRedisCacheEvict( - final RedisCacheEvict ann, final Object target) { - final String name = AnnotationTargets.extractTargetName(target); + private void addEvict( + final List operations, + final List policyOperations, + final RedisCacheEvict annotation, + final Object target) { log.trace("Parsing @RedisCacheEvict annotation for target: {}", target); + final RedisCacheAttributes attributes = projector.from(annotation); // 使用 Spring 标准的 CacheEvictOperation.Builder,确保 getClass() 返回 // CacheEvictOperation.class —— 这样 CacheAspectSupport 的 CacheOperationContexts // 能正确按类型索引(可缓存/可放入/可清除三桶)。ResiCache 增强字段(ttl/bloom/ - // early-expiration 等)不进 Spring operation,由本解析结果的 policy snapshot + // early-expiration 等)不进 Spring operation,由同一份投影的 policy 面 // 提供给 RedisCacheRegister 查询。(@RedisCacheEvict 的 sync/syncTimeout 是 // ResiCache 扩展,Spring 原生 CacheEvictOperation 无此概念,此处不投影—— // 与 Spring 原生 @CacheEvict 行为一致。) final CacheEvictOperation.Builder builder = new CacheEvictOperation.Builder(); - builder.setName(name); - builder.setCacheNames( - ann.value().length > 0 ? ann.value() : ann.cacheNames()); - - // 5 文本字段 + 2 special 字段(cacheWide + beforeInvocation)填充委派 - BuilderPopulator.populate(builder, ann, - List.of( - BuilderPopulator.TextField.textField( - RedisCacheEvict::key, CacheEvictOperation.Builder::setKey), - BuilderPopulator.TextField.textField( - RedisCacheEvict::cacheResolver, CacheEvictOperation.Builder::setCacheResolver), - BuilderPopulator.TextField.textField( - RedisCacheEvict::condition, CacheEvictOperation.Builder::setCondition), - BuilderPopulator.TextField.textField( - RedisCacheEvict::keyGenerator, CacheEvictOperation.Builder::setKeyGenerator), - BuilderPopulator.TextField.textField( - RedisCacheEvict::cacheManager, CacheEvictOperation.Builder::setCacheManager) - ), - List.of( - (b, a) -> b.setCacheWide(a.allEntries()), - (b, a) -> b.setBeforeInvocation(a.beforeInvocation()))); - + builder.setName(AnnotationTargets.extractTargetName(target)); + attributes.applyTo(builder); final CacheEvictOperation operation = builder.build(); log.debug("Built CacheEvictOperation: {}", operation); - return operation; + operations.add(operation); + + if (target instanceof Method method) { + policyOperations.add(RedisCacheEvictOperation.fromAttributes( + method, annotation.key(), attributes)); + } } /** - * 解析 @RedisCachePut 注解. - * - * @param ann 注解实例 - * @param target 方法或类对象 - * @return 缓存操作 + * {@code @RedisCachePut}:一份投影 → AOP operation + policy operation。 */ - private CacheOperation parseRedisCachePut( - final RedisCachePut ann, final Object target) { - final String name = AnnotationTargets.extractTargetName(target); + private void addPut( + final List operations, + final List policyOperations, + final RedisCachePut annotation, + final Object target) { log.trace("Parsing @RedisCachePut annotation for target: {}", target); + final RedisCacheAttributes attributes = projector.from(annotation); // 使用 Spring 标准的 CachePutOperation.Builder,确保 getClass() 返回 // CachePutOperation.class —— 这样 CacheAspectSupport 的 CacheOperationContexts // 能正确按类型索引(可缓存/可放入/可清除三桶)。ResiCache 增强字段(ttl/bloom/ - // nullValue/early-expiration 等)不进 Spring operation,由 policy snapshot + // nullValue/early-expiration 等)不进 Spring operation,由同一份投影的 policy 面 // 提供给 RedisCacheRegister 查询。 final CachePutOperation.Builder builder = new CachePutOperation.Builder(); - builder.setName(name); - builder.setCacheNames( - ann.value().length > 0 ? ann.value() : ann.cacheNames()); - - // 6 文本字段 + 0 special 字段委派(@RedisCachePut 不携带 Spring 标准 - // CachePutOperation 没有的特殊字段,只是 key+condition+unless 等基础文本) - BuilderPopulator.populate(builder, ann, - List.of( - BuilderPopulator.TextField.textField( - RedisCachePut::key, CachePutOperation.Builder::setKey), - BuilderPopulator.TextField.textField( - RedisCachePut::condition, CachePutOperation.Builder::setCondition), - BuilderPopulator.TextField.textField( - RedisCachePut::unless, CachePutOperation.Builder::setUnless), - BuilderPopulator.TextField.textField( - RedisCachePut::keyGenerator, CachePutOperation.Builder::setKeyGenerator), - BuilderPopulator.TextField.textField( - RedisCachePut::cacheManager, CachePutOperation.Builder::setCacheManager), - BuilderPopulator.TextField.textField( - RedisCachePut::cacheResolver, CachePutOperation.Builder::setCacheResolver) - ), - List.of()); - + builder.setName(AnnotationTargets.extractTargetName(target)); + attributes.applyTo(builder); final CachePutOperation operation = builder.build(); log.debug("Built CachePutOperation: {}", operation); - return operation; + operations.add(operation); + + if (target instanceof Method method) { + policyOperations.add(RedisCachePutOperation.fromAttributes( + method, annotation.key(), attributes)); + } + } + + /** + * Spring 原生 {@code @Cacheable}:只产出 policy operation —— AOP 面由 + * {@link SpringAnnotationAdapter} 负责,避免同一注解解析两次。 + */ + private void addSpringCacheablePolicy( + final List policyOperations, + final Cacheable annotation, + final Object target) { + if (target instanceof Method method) { + policyOperations.add(springCacheableAdapter.create(method, annotation, annotation.key())); + } + } + + record ParsedAnnotations( + List operations, + List policyOperations, + PolicyIndex policyIndex) { + + ParsedAnnotations( + List operations, + List policyOperations) { + this(operations, policyOperations, PolicyIndex.of(policyOperations)); + } + + ParsedAnnotations { + operations = List.copyOf(operations); + policyOperations = List.copyOf(policyOperations); + } + + /** + * 按 kind + cacheName 取 policy operation,未命中返回 {@code null}。 + * + *

查找与声明顺序无关({@link PolicyIndex} 在快照构造时一次建成); + * 同一 kind/cacheName 被多次声明时后声明者覆盖先声明者。 + */ + @Nullable + CacheOperation policy(OperationKind kind, String cacheName) { + return policyIndex.find(kind, cacheName); + } + } + + /** + * {@code kind + cacheName → policy operation} 的不可变索引 —— 覆盖语义 + * ("后声明者覆盖先声明者")只在本类定义一次,读取方不再向后扫描列表。 + */ + record PolicyIndex(Map> byKind) { + + static PolicyIndex of(List policyOperations) { + EnumMap> byKind = + new EnumMap<>(OperationKind.class); + for (final CacheOperation operation : policyOperations) { + for (final OperationKind kind : OperationKind.values()) { + if (!kind.operationType().isInstance(operation)) { + continue; + } + final Map byName = + byKind.computeIfAbsent(kind, ignored -> new HashMap<>()); + for (final String cacheName : operation.getCacheNames()) { + byName.put(cacheName, operation); + } + } + } + return new PolicyIndex(Map.copyOf(byKind)); + } + + @Nullable + CacheOperation find(OperationKind kind, String cacheName) { + Map byName = byKind.get(kind); + return byName == null ? null : byName.get(cacheName); + } } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BuilderPopulator.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BuilderPopulator.java index 08daa925..4be34d75 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BuilderPopulator.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/BuilderPopulator.java @@ -15,7 +15,8 @@ * 注解 → Builder 字段填充的 deep seam. * *

problem (背景):两条注解 → Spring {@code CacheOperation} 解析路径 - * ({@link AnnotationParser} 解析 {@code @RedisCacheable/@RedisCacheEvict/@RedisCachePut}, + * ({@link RedisCacheAttributes#applyTo(org.springframework.cache.interceptor.CacheableOperation.Builder)} + * 把 ResiCache 注解投影出的 AOP 面填入 Spring builder, * {@link SpringAnnotationAdapter} 解析 Spring {@code @Cacheable/@CachePut/@CacheEvict}) * 各持有 3 个近镜像的 builder 方法,共 6 处.每个方法都遵循同一形状: *

    @@ -23,20 +24,21 @@ *
  1. {@code setName(name)}
  2. *
  3. {@code setCacheNames(value-or-cacheNames)}
  4. *
  5. 6 个文本字段({@code key / condition / unless / keyGenerator / cacheManager / - * cacheResolver})逐个做{@code if (hasText) setX} 守卫式赋值 — 18 处 in - * {@code AnnotationParser} + 17 处 in {@code SpringAnnotationAdapter}
  6. + * cacheResolver})逐个做{@code if (hasText) setX} 守卫式赋值 — 6 处 in + * {@code RedisCacheAttributes#applyToSpringCommonFields} + 17 处 in + * {@code SpringAnnotationAdapter} *
  7. 1-2 个 special 字段({@code sync} / {@code cacheWide} / {@code beforeInvocation})直接赋值
  8. *
  9. {@code build()}
  10. *
* - *

两类的实现各自把同一形状重写一次 — 添加 1 个新 ResiCache 注解字段需同时改两个类的 - * 3 个方法共 6 个触点,且 {@code AnnotationParser} 不会复用 {@code SpringAnnotationAdapter} + *

两处的实现各自把同一形状重写一次 — 添加 1 个 AOP 面注解字段需同时改两处, + * 且 AOP 面不会复用 {@code SpringAnnotationAdapter} * 已有的私有 {@code applyText} helper.同一形状在两文件中独立漂移. * *

solution:把"形状 → 字段填充"收口到本类两个 seam: *

* - * @see AnnotationParser + * @see RedisCacheAttributes * @see SpringAnnotationAdapter */ @UtilityClass @@ -162,8 +165,8 @@ public static B populate( *

本方法用 {@link BiConsumer} 把 builder 也传入,允许 setter 在 lambda 体内捕获 builder * 实例(适配 Lombok 链式 builder 写法). * - *

{@link AnnotationParser} 18 处 - * {@code if (StringUtils.hasText(ann.x())) builder.setX(ann.x());} 样板 + *

{@link RedisCacheAttributes} 与 {@link SpringAnnotationAdapter} 的 + * {@code if (StringUtils.hasText(...)) builder.setX(...);} 样板 * 经本方法统一处理,消除重复的 if-守卫. * * @param builder 目标 builder diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributeSink.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributeSink.java index cdf015e2..c9c27ec4 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributeSink.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributeSink.java @@ -12,7 +12,10 @@ *

This interface is the real single source: {@link RedisCacheAttributes} holds * ONE {@code COMMON_SINKS} constant typed against this interface, and the three builders realize * it. A missing or renamed setter on any builder is a compile error, not a silent drift. - * Adding a common field is 2 touch points (one interface method + one COMMON_SINKS entry). + * Adding a common field is 2 touch points on this seam (one interface method + one + * COMMON_SINKS entry); the full end-to-end inventory — annotation declaration, projector, + * AOP face, builders, {@code CachePolicyView} — is enumerated in + * {@link RedisCacheAttributesProjector}. * *

Covariant returns: each interface method returns {@code RedisCacheAttributeSink}. * The concrete builders' chainable setters already return their own {@code Builder} type, which diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java index 74937f64..46705a0f 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java @@ -10,6 +10,10 @@ import java.util.function.Function; import lombok.Builder; import lombok.Value; +import org.springframework.cache.interceptor.CacheEvictOperation; +import org.springframework.cache.interceptor.CacheOperation; +import org.springframework.cache.interceptor.CachePutOperation; +import org.springframework.cache.interceptor.CacheableOperation; /** * Redis 缓存注解的内部投影层:把三个公开注解({@code @RedisCacheable} / @@ -46,6 +50,11 @@ * 重载;共享字段的 setter 契约由 {@link RedisCacheAttributeSink} 统一声明,差异字段由 * 各重载末尾的链式 setter 处理。 * + *

两面一源:{@code applyTo} 重载同时覆盖 policy 面({@code Redis*Operation.Builder}) + * 与 AOP 面(Spring 原生 {@code CacheableOperation.Builder} 等)。同一个注解只投影成本类 + * 一个实例,两面再从这个实例派生,因此 AOP operation 与 policy operation 对同一字段 + * 不可能给出不同解释。 + * *

共享字段 vs 差异字段: 14 个共享字段由本类的 {@code COMMON_SINKS} * 与 {@code populate} 统一迭代;差异字段由各 {@code applyTo} 重载末尾链式 setter 管理。 * 差异字段中,Evict 缺 {@code unless/type/cacheNullValues/randomTtl/variance} 5 项, @@ -249,4 +258,66 @@ public RedisCacheEvictOperation.Builder applyTo(RedisCacheEvictOperation.Builder .allEntries(allEntries) .beforeInvocation(beforeInvocation); } + + // ======================= AOP 面适配器 ======================= + + /** + * AOP 面共享字段的单一声明 —— 三副 Spring builder 的共同父类 + * {@link CacheOperation.Builder} 承载 {@code cacheNames} + 6 文本字段,故只写一遍; + * 文本字段保留 AOP 路径一贯的 {@code hasText} 守卫(Spring builder 视空串为"未设置")。 + * + *

新增一个 AOP 面共享字段 = 本方法 1 行,三个注解族同时生效。 + */ + private void applyToSpringCommonFields(CacheOperation.Builder b) { + b.setCacheNames(cacheNames); + BuilderPopulator.applyText(b, key, CacheOperation.Builder::setKey); + BuilderPopulator.applyText(b, condition, CacheOperation.Builder::setCondition); + BuilderPopulator.applyText(b, keyGenerator, CacheOperation.Builder::setKeyGenerator); + BuilderPopulator.applyText(b, cacheManager, CacheOperation.Builder::setCacheManager); + BuilderPopulator.applyText(b, cacheResolver, CacheOperation.Builder::setCacheResolver); + } + + /** + * 本值对象的 AOP 面 → {@link CacheableOperation.Builder}。 + * + *

为什么 AOP 面不能直接用 policy op:{@code CacheAspectSupport.CacheOperationContexts} + * 以 {@code op.getClass()} 为桶键(按 {@code CacheableOperation.class} / + * {@code CachePutOperation.class} / {@code CacheEvictOperation.class} 取用),所以 AOP 面 + * 必须是 Spring 原生 operation —— {@link RedisCacheableOperation} 会落进没有读取方的桶。 + * 两面因此都从同一份{@link RedisCacheAttributes} 派生:同一个注解只投影一次, + * AOP 面与 policy 面对同一字段不可能给出不同解释。{@code name} 由 caller 预置。 + * + *

注:传入 {@link RedisCacheableOperation.Builder} 时重载解析选中更具体的 policy 面 + * {@link #applyTo(RedisCacheableOperation.Builder)} —— 编译期规则,非隐式行为。 + */ + public CacheableOperation.Builder applyTo(CacheableOperation.Builder b) { + applyToSpringCommonFields(b); + BuilderPopulator.applyText(b, unless, CacheableOperation.Builder::setUnless); + b.setSync(sync); + return b; + } + + /** + * 本值对象的 AOP 面 → {@link CachePutOperation.Builder}。 + * + *

{@code sync} 不是 Spring {@code CachePutOperation} 的概念,故不进这一面(仍进 policy 面)。 + */ + public CachePutOperation.Builder applyTo(CachePutOperation.Builder b) { + applyToSpringCommonFields(b); + BuilderPopulator.applyText(b, unless, CachePutOperation.Builder::setUnless); + return b; + } + + /** + * 本值对象的 AOP 面 → {@link CacheEvictOperation.Builder}。 + * + *

Cachable/Put 面的子集 + Evict-only:{@code unless} 无槽位,{@code allEntries} / + * {@code beforeInvocation} 落进 Spring 的 {@code cacheWide} / {@code beforeInvocation}。 + */ + public CacheEvictOperation.Builder applyTo(CacheEvictOperation.Builder b) { + applyToSpringCommonFields(b); + b.setCacheWide(allEntries); + b.setBeforeInvocation(beforeInvocation); + return b; + } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java index 125ba473..fafe37db 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java @@ -8,7 +8,6 @@ import io.github.davidhlp.spring.cache.redis.annotation.RedisCachePut; import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; import io.github.davidhlp.spring.cache.redis.protection.refresh.EarlyExpirationMode; -import org.springframework.stereotype.Component; /** * 把 {@code @RedisCacheable / @RedisCachePut / @RedisCacheEvict} 三个公开注解的属性 @@ -27,19 +26,36 @@ *

Spring 原生 {@code @Cacheable} 由 {@link SpringCacheableAdapter} 内部直接构造, * 无需投影层。 * - *

seam 收敛:三个 {@code from(annotation)} 公共面之下,22 个共享字段 - * 的 builder 链下沉到单一 {@code project(FieldSource, boolean, boolean)} 方法 - * + 三个轻量 {@code extractFrom(annotation)} 提取器。Cacheable / Put 的 Evict-only - * 字段显式传 {@code false},Evict 则传入注解值。新增 - * 共享字段:1 处改 {@link FieldSource} + 1 处改 {@code project()} body + 3 处改 - * {@code extractFrom()}(或部分子集),共享一份 builder 链。 + *

seam 收敛:{@link AnnotationParser} 对每个注解只调用本类一次,得到的 + * {@link RedisCacheAttributes} 同时喂给 AOP 面与 policy 面 —— 本类是 + * "注解 → operation 字段" 的唯一映射,两侧不再各自读注解、各自解释同一字段。 + * 三个 {@code from(annotation)} 公共面之下,共享字段的 builder 链下沉到单一 + * {@code project(FieldSource, boolean, boolean)} 方法 + 三个轻量 + * {@code extractFrom(annotation)} 提取器,共享一份 builder 链。 + * Cacheable / Put 的 Evict-only 字段显式传 {@code false},Evict 则传入注解值。 + * + *

新增一个共享字段的真实触点(以 {@code useBloomFilter} 的 grep 口径实测, + * 2026-09 复核:{@code grep -rn 'useBloomFilter\|UseBloomFilter' src/main/java} = 35 行 / + * 14 文件,其中 2 行是读取方、6 行在本类):3 个注解声明 + 本类 + * {@link FieldSource} 组件 + {@code project()} 一行 + 3 个 {@code extractFrom()} 参数 + + * {@link RedisCacheAttributes} 字段 + {@link RedisCacheAttributeSink} 方法 + + * {@code COMMON_SINKS} 一行 + 3 个 policy Builder 的字段/setter + 3 个 Operation 构造赋值 + + * {@code CachePolicyView} 链路。本类只收敛其中 6 行;其余是编译期强制的适配器 + * (漏改即编译失败),不是可漂移的重复映射。AOP 面共享字段({@code cacheNames / key / + * keyGenerator / cacheManager / cacheResolver / condition})的映射各自只有 + * {@link RedisCacheAttributes#applyTo(org.springframework.cache.interceptor.CacheableOperation.Builder)} + * 一个声明点,与三个注解族无关。 * *

{@code expectedInsertions} 类型契约已统一:三个公开注解均使用 {@code long} * 并以 {@code 100000L} 为默认值,与 {@link RedisCacheAttributes#expectedInsertions} * 一致。本投影器只做无差别映射,不执行隐式拓宽或窄化。 * + *

非 bean:本类与 {@link SpringCacheableAdapter} 由 {@link AnnotationParser} + * 直接 {@code new} 构造 —— 它们是解析器的内部协作器,不是可替换的扩展点。此前二者标注 + * {@code @Component} 但没有任何注入方(唯一的外部构造点已随两参构造器一并删除), + * 2026-09 复核后去掉注解。 + * */ -@Component class RedisCacheAttributesProjector { /** diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheRegister.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheRegister.java index 9dc5e12a..9e07c43a 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheRegister.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheRegister.java @@ -2,7 +2,6 @@ import java.lang.reflect.Method; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -144,10 +143,11 @@ void cacheAliasIfCurrent( // ============================ 查询(单一 seam)============================ /** - * 查询一个缓存操作 —— 从元素快照按 kind + cacheName 过滤。 + * 查询一个缓存操作 —— 从元素快照按 kind + cacheName 查索引。 * - *

类型不匹配或未命中视为未命中;同一 kind/cacheName 的多次注册 - * 保持覆盖语义,返回最新 operation。 + *

类型不匹配或未命中视为未命中;同一 kind/cacheName 的多次声明保持覆盖语义 + * (后声明者胜),该语义由 {@link AnnotationParser.PolicyIndex} 在快照构造时一次建定, + * 读取侧不再依赖列表扫描顺序。 */ @SuppressWarnings("unchecked") public O get(String name, AnnotatedElementKey elementKey, OperationKind kind) { @@ -155,15 +155,9 @@ public O get(String name, AnnotatedElementKey element Class targetClass = MetadataKeys.extractTargetClass(elementKey); AnnotationParser.ParsedAnnotations snapshot = method == null || targetClass == null ? null : getSnapshot(method, targetClass); - if (snapshot != null) { - List policies = snapshot.policyOperations(); - for (int i = policies.size() - 1; i >= 0; i--) { - CacheOperation operation = policies.get(i); - if (kind.operationType().isInstance(operation) - && operation.getCacheNames().contains(name)) { - return (O) operation; - } - } + final CacheOperation operation = snapshot == null ? null : snapshot.policy(kind, name); + if (operation != null) { + return (O) operation; } log.debug("{} operation not found: name={}, elementKey={}", kind.tag(), name, elementKey); return null; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SpringCacheableAdapter.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SpringCacheableAdapter.java index 494dde7c..9a431560 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SpringCacheableAdapter.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SpringCacheableAdapter.java @@ -7,7 +7,6 @@ import java.lang.reflect.Method; import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Component; /** * Spring 原生 {@link Cacheable @Cacheable} 注解 → {@link RedisCacheableOperation} 的适配工厂。 @@ -21,8 +20,9 @@ * *

{@code toAttributes} 与 {@code materialize} 因承载 Spring→ResiCache 字段映射的非平凡逻辑, * 保留为命名 seam。 + * + *

由 {@link AnnotationParser} 直接 {@code new} —— 无注入方,故不是 bean。 */ -@Component class SpringCacheableAdapter { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java index 2b5e0a71..e2c6ba35 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java @@ -151,6 +151,47 @@ void compositeAnnotationExpands() throws Exception { .containsExactly(CacheableOperation.class, CacheEvictOperation.class, CachePutOperation.class); } + @Test + @DisplayName("both faces of one annotation come from one projection") + void bothFacesComeFromOneProjection() throws Exception { + Method method = method("read"); + + RedisCacheableOperation policy = (RedisCacheableOperation) execute(method).get(0); + CacheableOperation aop = (CacheableOperation) + operationSource.getCacheOperations(method, Matrix.class).iterator().next(); + + assertThat(aop.getCacheNames()).isEqualTo(policy.getCacheNames()); + assertThat(aop.getKey()).isEqualTo(policy.getKey()); + assertThat(aop.getCondition()).isEqualTo(policy.getCondition()); + assertThat(aop.getUnless()).isEqualTo(policy.getUnless()); + assertThat(aop.isSync()).isEqualTo(policy.isSync()); + } + + @Test + @DisplayName("value/cacheNames alias resolution is shared by both faces") + void aliasResolutionIsSharedByBothFaces() throws Exception { + Method method = method("aliased"); + + RedisCacheableOperation policy = (RedisCacheableOperation) execute(method).get(0); + CacheableOperation aop = (CacheableOperation) + operationSource.getCacheOperations(method, Matrix.class).iterator().next(); + + assertThat(aop.getCacheNames()).containsExactly("alias-cache"); + assertThat(policy.getCacheNames()).containsExactly("alias-cache"); + assertThat(resolve("alias-cache", io.github.davidhlp.spring.cache.redis.chain.CacheOperation.GET)) + .isSameAs(policy); + } + + @Test + @DisplayName("repeated declarations for one kind and cache name resolve to the last") + void repeatedDeclarationsResolveToLast() throws Exception { + Method method = method("repeated"); + + assertThat(execute(method)).hasSize(2); + assertThat(resolve("repeat-cache", io.github.davidhlp.spring.cache.redis.chain.CacheOperation.GET) + .getTtl()).isEqualTo(2L); + } + private List execute(Method method) { AnnotationParser.ParsedAnnotations parsed = new AnnotationParser().parse(method); register.registerSnapshot(method, Matrix.class, parsed); @@ -212,5 +253,17 @@ public void evict(String id) { public String composite(String id) { return id; } + + @RedisCacheable(value = "alias-value", cacheNames = "alias-cache", key = "#id", ttl = 77) + public String aliased(String id) { + return id; + } + + @RedisCaching(redisCacheable = { + @RedisCacheable(cacheNames = "repeat-cache", key = "#id", ttl = 1), + @RedisCacheable(cacheNames = "repeat-cache", key = "#id", ttl = 2)}) + public String repeated(String id) { + return id; + } } } From 4f714a0bc675ff76c76ddfa3612518b664ccc969 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 01:44:36 +0800 Subject: [PATCH 11/56] fix(cache): keep the configuration stereotype the member gate needs The nested proxy-eligibility gate is a member configuration class, and Spring processes member classes only for a @Component-annotated configuration, so dropping @Configuration from the outer class silently removed the proxy advisor and interceptor (caught by the default-assembly contract test). The outer keeps the stereotype; the inner gate stays without one so the scan registers the outer exactly once. The contract test now reads the aliased `classes` attribute it actually declares. --- .../cache/redis/cache/RedisProCacheConfiguration.java | 4 ++-- .../cache/redis/cache/RedisProxyCachingConfiguration.java | 6 ++++-- .../redis/cache/RedisProCacheConfigurationContractTest.java | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java index 25bd4b59..c1667d30 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java @@ -29,8 +29,8 @@ @Slf4j @Configuration(proxyBeanMethods = false) @Import({ - // RedisCacheAutoConfiguration scans the internal runtime package. These two - // configurations are the intentional scan exclusions and remain explicit. + // 显式导入:ResolvedMetricsConfiguration 不是扫描候选,RedisProxyCachingConfiguration + // 必须在不做内部包扫描的上下文里同样完成装配。 ResolvedMetricsConfiguration.class, RedisProxyCachingConfiguration.class }) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java index 573bf374..985ec56b 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProxyCachingConfiguration.java @@ -8,6 +8,7 @@ import org.springframework.cache.interceptor.CacheOperationSource; import org.springframework.cache.interceptor.KeyGenerator; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; /** @@ -17,9 +18,10 @@ * {@code RedisProCacheManager}。这避免默认 manager 已注册后代理条件被误判为不满足; * 用户提供任意其他 {@code CacheManager} 时,默认 manager 与代理一起 back off。 * - *

由 {@code RedisProCacheConfiguration} 显式 {@code @Import} 注册,故内层同样不带组件注解: - * 组件扫描不会重复注册该选择门。 + *

本类保持 @Configuration:内层选择门是成员类,只有带组件注解的配置类才会被递归处理。 + * 内层不带组件注解,因此组件扫描只注册本类一次,选择门只经由成员类处理一次。 */ +@Configuration(proxyBeanMethods = false) @Role(BeanDefinition.ROLE_INFRASTRUCTURE) class RedisProxyCachingConfiguration { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index 621845e0..4831597d 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -142,7 +142,7 @@ void entry_componentScan_excludesOperatorBoundaryByClass() { assertThat(scan).isNotNull(); assertThat(java.util.Arrays.stream(scan.excludeFilters()) .filter(filter -> filter.type() == FilterType.ASSIGNABLE_TYPE) - .flatMap(filter -> java.util.Arrays.stream(filter.value())) + .flatMap(filter -> java.util.Arrays.stream(filter.classes())) .toList()) .containsExactly(SerializationMigrationOperatorConfiguration.class); } From 50ba260e8e7d81191d365c8a9628e9078c673723 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 08:54:57 +0800 Subject: [PATCH 12/56] docs(architecture): state assembly ownership as class identity, not patterns --- docs/ARCHITECTURE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1513bc32..88a3d763 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,10 +27,10 @@ RedisCacheAutoConfiguration `resi-cache.enabled`; it does not add `@EnableCaching`. The internal component scan is deliberately limited to `io.github.davidhlp.spring.cache.redis.cache` and excludes test classes plus the operator-boundary assembly root, which is -named by class. Configurations registered by an explicit import carry no -component stereotype, so no name pattern stands in for class identity — a -class rename fails compilation instead of silently changing a context. Host -application packages are not scanned by the library. +named by class. Runtime bean ownership is never expressed as a name pattern: +classes that only their boundary may register carry no component stereotype, +and a class rename fails compilation instead of silently changing the +assembled set. Host application packages are not scanned by the library. The operator CLI (`SerializationMigrationCli`) is the second assembly boundary: its context names the internal migration beans by class through From d92fd685a533d6b3a3b839b7a6229f32d042cd75 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 08:55:03 +0800 Subject: [PATCH 13/56] docs: correct Cacheable spelling in eviction projection Javadoc --- .../davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java index 46705a0f..cd8ee253 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java @@ -311,7 +311,7 @@ public CachePutOperation.Builder applyTo(CachePutOperation.Builder b) { /** * 本值对象的 AOP 面 → {@link CacheEvictOperation.Builder}。 * - *

Cachable/Put 面的子集 + Evict-only:{@code unless} 无槽位,{@code allEntries} / + *

Cacheable/Put 面的子集 + Evict-only:{@code unless} 无槽位,{@code allEntries} / * {@code beforeInvocation} 落进 Spring 的 {@code cacheWide} / {@code beforeInvocation}。 */ public CacheEvictOperation.Builder applyTo(CacheEvictOperation.Builder b) { From 9a2cc9719e33d59748369a690451df0e08339349 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 08:59:44 +0800 Subject: [PATCH 14/56] docs(compatibility): record TTL precedence and annotation-default divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the ratified T8 (c8) "preserve + document" second half: TtlPolicy already concentrates TTL precedence in code, and this records it in the canonical docs without touching any default value. Precedence semantics (owner: docs/REFERENCE.md) state the verified order — annotation ttl>0 (default 60s, jittered) > Duration parameter carrying resi-cache.default-ttl (only when no method-level TTL is set; zero/negative parameter = permanent) > null-parameter fallback 60s — all resolved once in TtlPolicy. The 60s-vs-configured-default divergence is recorded as a supported-behaviour limitation in COMPATIBILITY.md; the two pages cross-reference instead of duplicating. Every reachable per-input outcome is preserved unchanged (annotated -> 60s; plain Spring / ttl=0 -> configured default; null Duration -> 60s). Verified against TtlPolicy.resolve and RedisProCacheProperties; docs-only change, scripts/ci/check-docs-contracts.sh passes. --- COMPATIBILITY.md | 1 + docs/REFERENCE.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index e0a77053..80596753 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -108,6 +108,7 @@ not require a cache flush. `clear` deletion counts and PUT_IF_ABSENT insertion. `withStatisticsCollector` fully rebinds statistics; lock-wait duration remains unreported (zero). - **Class-level cache annotations**: Spring operation resolution sees class-level ResiCache annotations, but the annotation chain does not apply their policy fields to methods without method-level annotations; this behavior is unchanged from `main`. +- **TTL default precedence**: because the method-level `@RedisCacheable`/`@RedisCachePut` `ttl` attribute defaults to `60` seconds, annotating a method without an explicit `ttl` expires its entries after `60s` even when `resi-cache.default-ttl` (default `30m`) is configured; the configured default applies only where no method-level TTL is declared (`ttl=0`, or a plain Spring `@Cacheable` in `SELECTIVE` mode). Ordered resolution and its single owner (`TtlPolicy`) are specified in [`docs/REFERENCE.md`](docs/REFERENCE.md). Which default should win for an annotation without an explicit `ttl` is an unresolved product decision; both values are preserved as current supported behaviour and neither changes on this build line. - **Refresh metadata**: the version-2 envelope persists the fields required by early-expiration policy and version CAS (`ttl`, `createdTime`, access/visit counters, `expired`, and `version`). `startNanoTime` is process-local and is diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 09f08c6b..76c506f1 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -59,6 +59,26 @@ README snippet when the properties class or generated metadata differs. TTL and disables Bloom, sync-lock, early-expiration, and null-value handlers; a per-mechanism true cannot re-enable one after global-off. +### TTL resolution precedence + +Effective TTL resolves once, in package-private `TtlPolicy` (`cache/`; see +[`ARCHITECTURE.md`](ARCHITECTURE.md)), from three inputs — the first match wins: + +1. **Annotation**: method-level `@RedisCacheable`/`@RedisCachePut` `ttl` when + greater than zero (attribute default `60`), optionally jittered by + `randomTtl`/`variance`. +2. **Duration parameter**: the write-path TTL Spring Data Redis passes from the + cache-level `resi-cache.default-ttl` (default `30m`, overridable per cache + under `caches.*.ttl`). It applies only when no method-level TTL is set — + `ttl=0`, or a plain Spring `@Cacheable` in `SELECTIVE` mode. A zero or + negative parameter yields a permanent entry (no expiry). +3. **No TTL context**: a `null` parameter (for example a caller-supplied + `RedisCacheConfiguration`) falls back to `TtlPolicy`'s `60`-second default. + +The consequence that branch 1's `60`-second annotation default overrides +`resi-cache.default-ttl` is recorded as a supported-behaviour limitation in +[`COMPATIBILITY.md`](../COMPATIBILITY.md). + ## Cache operation outcomes | Operation | Current behavior | From eb724afa26a43475b3bcdf072cf1749609385dfc Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 09:06:33 +0800 Subject: [PATCH 15/56] refactor(cache): delete unreachable degradation modes behind always-assembled collaborators The null-handling branches in RedisProCache, RedisProCacheWriter, CacheOperationResolver and LoaderOrchestrator existed only so tests could pass null; production wires the collaborators unconditionally (verified against RedisProCacheConfiguration and PublicSurfaceContractTest / external- consumer surface). HandlerResult is untouched so the public shouldTerminate() contract stays intact. - make the loader-path collaborators (operationResolver, bloomGate, syncSupport, syncLockTimeout) requireNonNull at construction, matching production assembly; drop ResiCacheFeatures.none() and per-caller null guards - fold the NullValueEncoder wrapper into CacheValueCodec.toValueBytes (Java null and NullValue.INSTANCE now share one null-placeholder branch) - delete the two CacheHandlerChainFactory convenience constructors - drop ChainEngine's re-check of HandlerResult's constructor-enforced invariant and the duplicate shouldHandle dispatch in AbstractCacheHandler - adapt the affected unit/integration tests to pass real collaborators --- .../redis/cache/AbstractCacheHandler.java | 8 +-- .../cache/redis/cache/ActualCacheHandler.java | 12 ++-- .../redis/cache/CacheHandlerChainFactory.java | 25 --------- .../redis/cache/CacheOperationResolver.java | 36 +++--------- .../cache/redis/cache/CacheValueCodec.java | 7 ++- .../spring/cache/redis/cache/ChainEngine.java | 6 -- .../cache/redis/cache/LoaderOrchestrator.java | 32 +++++------ .../cache/redis/cache/NullValueEncoder.java | 55 ------------------- .../cache/redis/cache/RedisProCache.java | 34 ++++-------- .../redis/cache/RedisProCacheWriter.java | 40 ++++---------- .../cache/redis/cache/ResiCacheFeatures.java | 21 ++----- .../ActualCacheHandlerIntegrationTest.java | 6 +- .../cache/CacheHandlerChainFactoryTest.java | 36 ++++++------ .../redis/cache/CacheValueCodecTest.java | 9 +++ ...EarlyExpirationHandlerIntegrationTest.java | 4 +- .../redis/cache/LoaderOrchestratorTest.java | 9 +-- .../cache/RedisProCacheIntegrationTest.java | 21 ++++++- .../cache/RedisProCacheLoadPathTest.java | 24 +++++++- ...RedisProCacheLoaderFailurePrivacyTest.java | 16 +++++- .../redis/cache/RedisProCacheManagerTest.java | 8 ++- .../cache/RedisProCacheWriterFailureTest.java | 5 +- .../RedisProCacheWriterStatisticsTest.java | 8 ++- 22 files changed, 172 insertions(+), 250 deletions(-) delete mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoder.java diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java index f6f4250b..61b684d7 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java @@ -152,7 +152,8 @@ protected void safeIncrementSemantic() { } /** - * handle 默认实现 — 由 Engine 调用。 + * handle 默认实现 — 单参形态没有剩余链可推进,委托二参形态并传入基类统一提供的 + * "无剩余链"句柄;shouldHandle 分发决策只在二参形态实现一次。 * *

Engine 已在调用本方法前完成: *

* - * 本方法只把单参调用转为统一的二参处理钩子,并提供显式的"无剩余链"句柄。 * 链推进由 {@link ChainEngine} 统一驱动。 */ @Override public HandlerResult handle(CacheContext context) { - return shouldHandle(context) - ? doHandle(context, NO_REMAINDER) - : HandlerResult.continueChain(); + return handle(context, NO_REMAINDER); } /** diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java index da1253a3..e1c32aa8 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java @@ -48,18 +48,18 @@ class ActualCacheHandler extends AbstractCacheHandler { private final RedisTemplate redisTemplate; private final ValueOperations valueOperations; - private final NullValueEncoder nullValueEncoder; + private final CacheValueCodec valueCodec; private final RefreshCancellation earlyExpirationExecutor; private final CacheErrorHandler errorHandler; public ActualCacheHandler( @Qualifier("redisCacheTemplate") RedisTemplate redisTemplate, ValueOperations valueOperations, - NullValueEncoder nullValueEncoder, + CacheValueCodec valueCodec, @Qualifier("earlyExpirationExecutor") RefreshCancellation earlyExpirationExecutor, CacheErrorHandler errorHandler) { this.redisTemplate = redisTemplate; this.valueOperations = valueOperations; - this.nullValueEncoder = nullValueEncoder; + this.valueCodec = valueCodec; this.earlyExpirationExecutor = earlyExpirationExecutor; this.errorHandler = errorHandler; } @@ -146,8 +146,7 @@ private CacheResult processCacheHit(CacheContext context, CachedValue cachedValu // 读路径默认不触发写操作,避免写放大。 // 如需 TTI(读取刷新 TTL),应使用 Spring Data Redis 的 RedisCacheConfiguration.enableTimeToIdle(), // 由 Redis 6.2+ 的 GETEX 命令实现,无需重写 value。 - byte[] result = nullValueEncoder.encodeForReturn( - cachedValue.getValue(), context.getCacheName(), context.getRedisKey()); + byte[] result = valueCodec.toValueBytes(cachedValue.getValue()); return CacheResult.success(result); } @@ -222,8 +221,7 @@ private CacheResult handlePutIfAbsent(CacheContext context) { context.getCacheName(), context.getRedisKey()); CachedValue existingValue = (CachedValue) valueOperations.get(context.getRedisKey()); if (existingValue != null) { - byte[] result = nullValueEncoder.encodeForReturn( - existingValue.getValue(), context.getCacheName(), context.getRedisKey()); + byte[] result = valueCodec.toValueBytes(existingValue.getValue()); return CacheResult.existing(result); } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java index 93712121..34131187 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java @@ -15,7 +15,6 @@ import java.util.*; import java.util.function.Function; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; /** @@ -106,30 +105,6 @@ static List protectionToggles() { return PROTECTION_TOGGLES; } - /** - * 便捷构造(无 observer bean)—单元测试用;Spring 装配走带 ResolvedMetrics 的 - * {@code @Autowired} 构造。 - */ - public CacheHandlerChainFactory(List handlers, - RedisProCacheProperties properties, - ObjectProvider meterRegistryProvider, - ChainEngine engine) { - this(handlers, properties, ResolvedMetrics.resolve(meterRegistryProvider, null), engine, - List.of()); - } - - /** - * Convenience constructor for tests without an injected Environment. - */ - public CacheHandlerChainFactory(List handlers, - RedisProCacheProperties properties, - ObjectProvider meterRegistryProvider, - ChainEngine engine, - List observers) { - this(handlers, properties, ResolvedMetrics.resolve(meterRegistryProvider, null), engine, - observers); - } - /** * 主构造 — P1-API-001-C:注入有序 {@link ChainObserver} beans 单一装配。 * {@code @Autowired} 使 Spring 在多个 public 构造器间选择本构造 diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheOperationResolver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheOperationResolver.java index a0d63baa..aec1a5f4 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheOperationResolver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheOperationResolver.java @@ -20,11 +20,10 @@ * *

problem:"读 ThreadLocal AnnotatedElementKey → 查 RedisCacheRegister"协议若在 * {@code RedisProCache} 与 {@code RedisProCacheWriter} 各持一份,两处 4 行近镜像任一写错 - * (null-safe 漏检查、log tag 漂移、查询命名空间不一致),另一边静默失效。 + * (log tag 漂移、查询命名空间不一致),另一边静默失效。 * *

solution:本类把"读 ThreadLocal key → 查 register"协议收口到单一 seam, - * 两个调用方简化为 {@code resolver.resolve(cacheName, operation)},null-safe + 命名空间 - * 选择 + 日志在一处。 + * 两个调用方简化为 {@code resolver.resolve(cacheName, operation)},命名空间选择 + 日志在一处。 * *

deletion test:删本类 → 两调用方各自重新实现 4 行镜像;ThreadLocal 协议与 * 日志形式在两处独立漂移。本 seam 挣得起存在代价。 @@ -49,13 +48,13 @@ class CacheOperationResolver { /** * Spring 装配构造入口:双依赖必传。 * - *

允许 {@code register} 为 null(测试场景关闭元数据查找,fallback 到 null), - * {@code methodResolver} 为 null 同理(null resolver 直接短路返回 null, - * 等价于"无 ThreadLocal 上下文")。 + *

「无元数据」不是本类的构造模式:无当前方法上下文时 + * {@link #resolve(String, CacheOperation)} 读到的 ThreadLocal key 为 null, + * 直接返回 null(见 {@link MethodMetadataResolver#currentKey()})。 */ @Autowired - public CacheOperationResolver(@Nullable MethodMetadataResolver methodResolver, - @Nullable RedisCacheRegister register) { + public CacheOperationResolver(MethodMetadataResolver methodResolver, + RedisCacheRegister register) { this.methodResolver = methodResolver; this.register = register; } @@ -65,9 +64,7 @@ public CacheOperationResolver(@Nullable MethodMetadataResolver methodResolver, * *

流程: *

    - *
  1. 若 {@link MethodMetadataResolver} 为 null,直接返回 null(无 ThreadLocal 上下文)
  2. *
  3. 读 ThreadLocal AnnotatedElementKey;为 null → 返回 null(无当前方法上下文)
  4. - *
  5. 若 {@link RedisCacheRegister} 为 null,返回 null(测试关闭 register)
  6. *
  7. 查 register;未命中 → 记 debug 日志,返回 null
  8. *
* @@ -86,9 +83,6 @@ public CacheOperationResolver(@Nullable MethodMetadataResolver methodResolver, */ @Nullable public CachePolicyView.Source resolve(@Nullable String cacheName, CacheOperation operation) { - if (methodResolver == null || register == null) { - return null; - } AnnotatedElementKey key = methodResolver.currentKey(); if (key == null) { return null; @@ -128,7 +122,7 @@ private CachePolicyView.Source lookup(String cacheName, AnnotatedElementKey key, */ @Nullable public MethodSnapshot capture() { - return methodResolver == null ? null : methodResolver.capture(); + return methodResolver.capture(); } /** @@ -138,20 +132,6 @@ public T runWithSnapshot( @Nullable MethodSnapshot snapshot, @Nullable Map mdcSnapshot, Supplier work) { - if (methodResolver == null) { - return work.get(); - } return methodResolver.runWithSnapshot(snapshot, mdcSnapshot, work); } - - /** - * Compatibility overload for synchronous callers. Async callers must use - * {@link #capture()} before queueing work. - */ - public T runWithSnapshot(Supplier work) { - if (methodResolver == null) { - return work.get(); - } - return methodResolver.runWithSnapshot(work); - } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java index e2cbf6b0..811456ed 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java @@ -54,13 +54,16 @@ public Object fromValueBytes(@NonNull byte[] valueBytes) { /** * 将 chain-facing value 写回 writer seam 所需的 value 字节。 * - * @param chainValue chain-facing value;{@link NullValue} 使用受限 Java 序列化,其他值使用 JSON + *

null 决策与 {@link NullValue} 决策同属本类:{@code null} 与 + * {@link NullValue#INSTANCE} 都写出受限 Java 序列化的 null 占位字节,其他值使用 JSON。 + * + * @param chainValue chain-facing value;{@code null} / {@link NullValue} 使用受限 Java 序列化,其他值使用 JSON * @return plain-JSON 或受限 Java 序列化后的 value 字节 * @throws SerializationException value 无法写出为 JSON */ @NonNull public byte[] toValueBytes(@Nullable Object chainValue) { - if (chainValue instanceof NullValue) { + if (chainValue == null || chainValue instanceof NullValue) { return SecureNullValueDeserializer.serializeNullValue(); } try { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java index 9910a225..9c9637da 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java @@ -158,12 +158,6 @@ private CacheResult driveChain(List snapshot, CacheContext context "CacheHandler returned null HandlerResult: " + current.getClass().getName()); } - if (result.decision() == null) { - // SPI 协议要求 HandlerResult 携带非 null decision,否则引擎无法分发控制流。 - throw new IllegalStateException( - "CacheHandler returned HandlerResult with null decision: " - + current.getClass().getName()); - } switch (result.decision()) { case CONTINUE: if (next.advanced()) { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java index 132d744d..763d1bec 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestrator.java @@ -27,7 +27,7 @@ *

    *
  1. Bloom 短路检查 — 经 {@link BloomGate#definiteMiss} 判定「确定不存在」 → * 返回 {@link LoadOutcome.BloomShortCircuited};caller 据此自增 miss counter 并返回 null
  2. - *
  3. Sync 路径 — {@code sync=true} + {@link SyncSupport} 在场 → + *
  4. Sync 路径 — {@code sync=true} → * {@link SyncSupport#executeSync} + 锁内 {@link #readThrough read-through protocol}; * 返回 {@link LoadOutcome.Loaded};锁内 loader 抛异常 → * {@link LoadOutcome.LoadFailed}(由 caller 翻译)
  5. @@ -130,22 +130,23 @@ public record LoadFailed(Throwable cause) implements LoadOutcome { /** * 生产构造:一次性绑定保护依赖与 cache-specific 回调,隐藏 loader 编排的 callback 组装细节。 * - *

    3 个回调是 loader 路径的必需操作,构造期分别以 {@link Objects#requireNonNull} 校验, - * 缺失即抛带参数名的 {@link NullPointerException};可选保护依赖保持既有可空语义。 + *

    3 个回调与 3 个保护协作对象(bloom / sync / sync-timeout)在生产均由 Spring 装配, + * 构造期分别以 {@link Objects#requireNonNull} 校验,缺失即抛带参数名的 + * {@link NullPointerException}(装配错误,不静默降级)。 */ LoaderOrchestrator( - @Nullable BloomGate bloomGate, - @Nullable SyncSupport syncSupport, - @Nullable SyncLockTimeout syncLockTimeout, + BloomGate bloomGate, + SyncSupport syncSupport, + SyncLockTimeout syncLockTimeout, Function redisKeyFn, Function doubleCheckFn, BiConsumer putAfterLoad) { - this.bloomGate = bloomGate; - this.syncSupport = syncSupport; - this.syncLockTimeout = syncLockTimeout; this.boundRedisKeyFn = Objects.requireNonNull(redisKeyFn, "redisKeyFn"); this.boundDoubleCheckFn = Objects.requireNonNull(doubleCheckFn, "doubleCheckFn"); this.boundPutAfterLoad = Objects.requireNonNull(putAfterLoad, "putAfterLoad"); + this.bloomGate = Objects.requireNonNull(bloomGate, "bloomGate"); + this.syncSupport = Objects.requireNonNull(syncSupport, "syncSupport"); + this.syncLockTimeout = Objects.requireNonNull(syncLockTimeout, "syncLockTimeout"); } /** @@ -170,8 +171,8 @@ LoadOutcome orchestrate( return new BloomShortCircuited<>(); } - // 2) Sync 路径 — sync=true 且 SyncSupport 在场才走;否则降级 default 路径 - if (operation != null && operation.isSync() && syncSupport != null) { + // 2) Sync 路径 — sync=true 才走;否则 default 路径 + if (operation != null && operation.isSync()) { return executeSyncLoad(cacheName, loader, key, operation); } @@ -190,10 +191,7 @@ private LoadOutcome executeSyncLoad( Callable loader, Object key, CachePolicyView.Source operation) { - SyncLockTimeout.Resolved timeout = syncLockTimeout != null - ? syncLockTimeout.resolve(operation) - : SyncLockTimeout.Resolved.fromSeconds( - SyncLockTimeout.DEFAULT_LOCK_TIMEOUT_SECONDS); + SyncLockTimeout.Resolved timeout = syncLockTimeout.resolve(operation); try { String lockKey = boundRedisKeyFn.apply(key); return syncSupport.executeSync( @@ -309,13 +307,13 @@ interface ThrowingSupplier { /** * Bloom 短路检查 — miss counter 自增下沉到 caller(orchestrator 不感知 metric)。 * - *

    前置条件任一缺失(operation null / 未启用 bloom / bloomGate null)→ return false(不短路)。 + *

    前置条件任一缺失(operation null / 未启用 bloom)→ return false(不短路)。 * 键派生经 {@link CacheKeys#fromRedisKey} 与链层 {@code BloomFilterHandler.add} 同源,杜绝 * actualKey/redisKey 漂移缺陷。 */ private boolean isBloomShortCircuited(String cacheName, String redisKey, @Nullable CachePolicyView.Source operation) { - if (operation == null || !operation.isUseBloomFilter() || bloomGate == null) { + if (operation == null || !operation.isUseBloomFilter()) { return false; } String bloomKey = CacheKeys.fromRedisKey(cacheName, redisKey).bloomKey(); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoder.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoder.java deleted file mode 100644 index 852ae116..00000000 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoder.java +++ /dev/null @@ -1,55 +0,0 @@ -package io.github.davidhlp.spring.cache.redis.cache; - - - - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.support.NullValue; -import org.springframework.lang.Nullable; -import org.springframework.stereotype.Component; - -/** - * Null-aware 字节编码器 seam — 单一职责:把“是否将 {@code null} 编码为 - * {@link NullValue#INSTANCE}”的 null 决策与实际字节生产解耦。 - * - *

    本类承接 null 决策层:{@code value == null ⇒ NullValue.INSTANCE}。 - * 字节生产由 {@link CacheValueCodec} 完成;本类不持有 value 字节格式细节。 - * - *

    {@code NullValueHandler} 负责 null 缓存决策;字节编码是实现细节,不属于可替换策略面。 - */ -@Slf4j -@Component -@RequiredArgsConstructor -class NullValueEncoder { - - private final CacheValueCodec valueCodec; - - /** - * 将缓存返回值编码为字节,完成 null 决策的最后一英里。 - * - *

    contract: - *

      - *
    • {@code value == null} ⇒ 先决策为 {@link NullValue#INSTANCE},再由 - * {@link CacheValueCodec} 使用受限 Java 序列化
    • - *
    • {@code value != null} ⇒ 原值交由 {@link CacheValueCodec} 编码
    • - *
    - * - * @param value 缓存返回值(可为 {@code null} 或任意类型) - * @param cacheName 缓存名(仅用于 debug 日志定位) - * @param key 缓存键(仅用于 debug 日志定位) - * @return 序列化后的字节;{@code value == null} 时为 {@code NullValue.INSTANCE} 字节 - */ - @Nullable - public byte[] encodeForReturn( - @Nullable Object value, String cacheName, String key) { - if (value == null) { - log.debug( - "Returning null value in standard format: cacheName={}, key={}", - cacheName, - key); - return valueCodec.toValueBytes(NullValue.INSTANCE); - } - return valueCodec.toValueBytes(value); - } -} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java index 15198892..3e4a33a8 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCache.java @@ -10,6 +10,7 @@ import io.github.davidhlp.spring.cache.redis.cache.metrics.CacheMetrics; import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; +import java.util.Objects; import java.util.concurrent.Callable; import org.springframework.cache.Cache; import org.springframework.data.redis.cache.RedisCache; @@ -43,7 +44,7 @@ public class RedisProCache extends RedisCache { */ private final RedisProCacheMetricsRegistry metricsRegistry; - /** 方法级策略解析器 — 仅 lookupPolicy 使用;null 时关闭元数据查找。 */ + /** 方法级策略解析器 — 仅 loader 路径使用;生产恒由 {@code RedisProCacheConfiguration} 装配。 */ private final CacheOperationResolver operationResolver; /** @@ -63,9 +64,9 @@ public class RedisProCache extends RedisCache { * 构造 ResiCache 实例 — 唯一构造入口。 * *

    单一 seam:本类是 ResiCache 与 Spring {@code RedisCache} 的扩展点。 - * 全部可选特性收口到单一 {@link ResiCacheFeatures} 值对象,「null = 该特性禁用」的契约 - * 只存在于 {@link ResiCacheFeatures} 一处。测试用 {@link ResiCacheFeatures#none()} 或 - * builder 显式声明启用的特性。 + * 可选特性收口到单一 {@link ResiCacheFeatures} 值对象。只有指标是可降解特性 + * ({@code meterRegistry} null ⇒ no-op);元数据解析与 protection 协作对象在生产始终 + * 装配,构造期校验非 null(缺失即装配错误,不静默降级)。 * *

    构造期委派 3 个 deep seam: *

      @@ -78,7 +79,7 @@ public class RedisProCache extends RedisCache { *
        *
      • {@code name / cacheWriter / cacheConfiguration} —— 必传,转发给 * {@code super(String, RedisCacheWriter, RedisCacheConfiguration)}
      • - *
      • {@code features} —— 可选特性集合(见 {@link ResiCacheFeatures};各字段 null 表示禁用)
      • + *
      • {@code features} —— 特性集合(见 {@link ResiCacheFeatures});非指标字段必传
      • *
      */ RedisProCache( @@ -88,7 +89,8 @@ public class RedisProCache extends RedisCache { ResiCacheFeatures features) { super(name, cacheWriter, cacheConfiguration); this.metricsRegistry = new RedisProCacheMetricsRegistry(features.getMeterRegistry(), name); - this.operationResolver = features.getOperationResolver(); + this.operationResolver = Objects.requireNonNull( + features.getOperationResolver(), "operationResolver"); // loader 路径编排器 build — protection 依赖 + cache-specific callbacks 一次性绑定; // 生产 get(key, loader) 只需传入 key/loader/operation,不再重复装配 3 个 callback。 this.loaderOrchestrator = new LoaderOrchestrator( @@ -131,6 +133,8 @@ public T get(Object key, Class type) { * 由 {@link LoaderOrchestrator#orchestrate} 承担,本方法: *
        *
      1. timed wrap(getTimer)(委派 {@link RedisProCacheMetricsRegistry#recordGet})
      2. + *
      3. 查当前方法的策略视图 — loader 路径恒为 GET 操作,故查 + * {@code @RedisCacheable} 命名空间(委派 {@link CacheOperationResolver#resolve})
      4. *
      5. 委派 orchestrator.orchestrate(...) 返回 {@link LoadOutcome}
      6. *
      7. switch 翻译 4 态 → 路径返回 / miss 自增 / 异常翻译
      8. *
      @@ -148,7 +152,7 @@ public T get(Object key, Class type) { @Override public T get(Object key, Callable loader) { return metricsRegistry.recordGet(() -> { - CachePolicyView.Source operation = lookupPolicy(); + CachePolicyView.Source operation = operationResolver.resolve(getName(), CacheOperation.GET); LoadOutcome outcome = loaderOrchestrator.orchestrate(getName(), loader, key, operation); return switch (outcome) { case LoaderOrchestrator.BloomShortCircuited ignored -> { @@ -206,22 +210,6 @@ RuntimeException translateFailure(Throwable cause, String cacheName) { return new RuntimeException("Failed to load cache value (cache=" + cacheName + ")", cause); } - /** - * 查找当前方法在本 cache 上的策略视图 —— 1 行委派。 - * - *

      委派 {@link CacheOperationResolver#resolve(String, CacheOperation)}:loader 路径 - * 恒为 GET 操作,故查 {@code @RedisCacheable} 命名空间。{@code operationResolver} 为 null - * 时直接返回 null(测试场景关闭元数据查找)。 - * - *

      返回稳定 {@link CachePolicyView.Source} 而非内部 operation 类型:链侧需要的只是 - * 策略字段(ttl / bloom / sync / …)。 - */ - private CachePolicyView.Source lookupPolicy() { - return operationResolver == null - ? null - : operationResolver.resolve(getName(), CacheOperation.GET); - } - @Override public void put(Object key, Object value) { metricsRegistry.recordPut(() -> super.put(key, value)); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriter.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriter.java index 7d3ba522..798310af 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriter.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriter.java @@ -34,9 +34,8 @@ * NullValueHandler → ActualCacheHandler * *

      本类持有单一 {@link CacheOperationResolver} seam —— 消除两处镜像 - * "读 ThreadLocal key → 查 register"协议(本类 {@code resolveOperation} 与 - * {@code RedisProCache} 的 lookup)漂移风险; - * {@code resolveOperation} 为 1 行委派。 + * "读 ThreadLocal key → 查 register"协议(本类各入口与 {@code RedisProCache} 的 loader 路径) + * 漂移风险;resolver 由生产装配保证非 null。 */ @Slf4j class RedisProCacheWriter implements RedisCacheWriter { @@ -142,12 +141,10 @@ public boolean supportsAsyncRetrieve() { } private CompletableFuture submitAsync(Supplier work) { - MethodSnapshot snapshot = operationResolver == null ? null : operationResolver.capture(); + MethodSnapshot snapshot = operationResolver.capture(); Map mdcSnapshot = MDC.getCopyOfContextMap(); return CompletableFuture.supplyAsync( - () -> operationResolver == null - ? work.get() - : operationResolver.runWithSnapshot(snapshot, mdcSnapshot, work)); + () -> operationResolver.runWithSnapshot(snapshot, mdcSnapshot, work)); } @Override @NonNull @@ -224,9 +221,10 @@ public void clear(@NonNull String name, @NonNull byte[] pattern) { // 构建上下文 —— keyPattern 前置进 buildContext,避免后置 mutate CacheContext context = buildContext( CacheOperation.CLEAN, name, keyPattern, actualKey, - null, null, null, resolveOperation(name, CacheOperation.CLEAN), keyPattern); + null, null, null, + operationResolver.resolve(name, CacheOperation.CLEAN), keyPattern); - CacheResult result = executeContext(context); + CacheResult result = cachedChain.execute(context); CacheErrorHandler.finalizeFailure(CacheOperation.CLEAN, name, result); if (result.isSuccess()) { recordCleanDeletes(name, result.deletedCount()); @@ -257,26 +255,12 @@ public CacheStatistics getCacheStatistics(@NonNull String cacheName) { return statistics.getCacheStatistics(cacheName); } - /** - * 解析方法级策略(布隆/同步锁/TTL/空值等)—— 1 行委派。 - * - *

      委派 {@link CacheOperationResolver#resolve(String, CacheOperation)};{@code operationResolver} 为 null - * 时直接返回 null(测试场景关闭元数据查找)。 - * - * @param cacheName 缓存名称 - * @param operation 当前链侧操作(决定查询的注册命名空间) - * @return 命中的策略视图;无元数据或未命中返回 null - */ - @Nullable - private CachePolicyView.Source resolveOperation(@NonNull String cacheName, CacheOperation operation) { - return operationResolver == null ? null : operationResolver.resolve(cacheName, operation); - } - /** * 统一的 CacheContext 构造 seam —— 5 个 SDR 入口(GET/PUT/PUT_IF_ABSENT/REMOVE/CLEAN) * 与带 operation 的 put 重载均经此构造。 * - *

      cacheOperation 由调用方解析:executeChain/clean 走 {@link #resolveOperation} 查 register, + *

      cacheOperation 由调用方解析:executeChain/clean 走 + * {@link CacheOperationResolver#resolve(String, CacheOperation)} 查 register, * put 5参重载直接传入已持有的 operation。keyPattern 仅 CLEAN 操作非 null —— 作为 * CacheContext direct field 前置设置,避免 clean 后置 mutate。 * @@ -357,11 +341,7 @@ private CacheResult executeChain( valueBytes != null ? valueCodec.fromValueBytes(valueBytes) : null; CacheContext context = buildContext( operation, name, redisKey, actualKey, valueBytes, deserializedValue, ttl, - resolveOperation(name, operation), null); - return executeContext(context); - } - - private CacheResult executeContext(CacheContext context) { + operationResolver.resolve(name, operation), null); return cachedChain.execute(context); } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java index e1469206..b6b075b2 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java @@ -17,8 +17,8 @@ * 需同时改动多个构造器 + bean 装配 + 各自 Javadoc。本值对象让该契约只存在一处:消费方 * 询问本对象,而非各自记忆可空语义;新增特性只动本类一处。 * - *

      可空语义:每个字段为 {@code null} 表示对应特性未启用,消费方走 null-safe 降级路径 - * (与原逐参数可空行为字节等价)。{@link #none()} 提供「全部禁用」的测试便捷入口。 + *

      可空语义:只有 {@code meterRegistry} 为 {@code null} 表示指标禁用(no-op 降级); + * 其余字段是生产恒装配的协作对象,消费方构造期校验非 null(装配错误即抛,不静默降级)。 */ @Value @Builder @@ -28,24 +28,15 @@ class ResiCacheFeatures { @Nullable MeterRegistry meterRegistry; - /** 布隆读侧穿透闸门 —— null 表示关闭缓存穿透防护(GET/loader 路径跳过布隆短路). */ - @Nullable + /** 布隆读侧穿透闸门 —— 生产恒装配. */ BloomGate bloomGate; - /** 方法级 operation 元数据解析器 —— null 表示关闭元数据查找. */ - @Nullable + /** 方法级 operation 元数据解析器 —— 生产恒装配. */ CacheOperationResolver operationResolver; - /** 分布式同步锁支持 —— null 表示关闭分布式锁(loader 走 Spring 默认本地锁). */ - @Nullable + /** 分布式同步锁支持 —— 生产恒装配. */ SyncSupport syncSupport; - /** 分布式锁超时解析规则 —— null 时回退内置默认(仅测试;生产始终装配). */ - @Nullable + /** 分布式锁超时解析规则 —— 生产恒装配. */ SyncLockTimeout syncLockTimeout; - - /** 全部特性禁用 —— 测试便捷入口. */ - public static ResiCacheFeatures none() { - return ResiCacheFeatures.builder().build(); - } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandlerIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandlerIntegrationTest.java index 74d30e42..a1ecccea 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandlerIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandlerIntegrationTest.java @@ -54,7 +54,7 @@ class ActualCacheHandlerIntegrationTest extends AbstractRedisIntegrationTest { private ValueOperations valueOperations; @Autowired - private NullValueEncoder nullValueEncoder; + private CacheValueCodec valueCodec; @Mock private RefreshCancellation earlyExpirationExecutor; @@ -71,7 +71,7 @@ void setUp() { handler = new ActualCacheHandler( redisTemplate, valueOperations, - nullValueEncoder, + valueCodec, earlyExpirationExecutor, errorHandler); } @@ -105,7 +105,7 @@ private ActualCacheHandler faultHandler(Exception onGet, Exception onSet, Except if (onDelete != null) { when(throwingTpl.delete("test:key")).thenThrow(onDelete); } - return new ActualCacheHandler(throwingTpl, throwingOps, nullValueEncoder, + return new ActualCacheHandler(throwingTpl, throwingOps, valueCodec, earlyExpirationExecutor, errorHandler); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java index 5e2a2126..00943e55 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java @@ -37,7 +37,7 @@ class CacheHandlerChainFactoryTest { @BeforeEach void setUp() { properties = mock(RedisProCacheProperties.class); - factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); } private CacheContext testContext() { return CacheContext.of(CacheInput.builder() @@ -59,7 +59,7 @@ class CreateChainTests { @Test @DisplayName("creates empty chain when no handlers provided") void createChain_noHandlers_createsEmptyChain() { - factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -74,7 +74,7 @@ void createChain_multipleHandlers_addsAllToChain() { new AnotherTestHandler(), new YetAnotherTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -89,7 +89,7 @@ void createChain_withPriorities_sortsCorrectly() { new BloomFilterTestHandler(), new SyncLockTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheResult result = factory.createChain().execute(testContext()); @@ -103,7 +103,7 @@ void createChain_noAnnotation_getsMaxPriority() { new TestCacheHandler(), new PriorityTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheResult result = factory.createChain().execute(testContext()); @@ -117,7 +117,7 @@ void createChain_multipleHandlers_linksCorrectly() { new TestCacheHandler(), new AnotherTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -137,7 +137,7 @@ void createChain_disabledHandlersGlobally_filtersOut() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(List.of("test-cache")); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -153,7 +153,7 @@ void createChain_kebabCaseMapping_worksCorrectly() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(List.of("test-cache")); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -169,7 +169,7 @@ void createChain_emptyDisabledList_keepsAllHandlers() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(Collections.emptyList()); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -185,7 +185,7 @@ void createChain_allDisabled_resultsInEmptyChain() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(List.of("test-cache", "another-test")); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -208,7 +208,7 @@ void protectionDisabled_preservesTtlAndActualCache() { List handlers = List.of( new BloomFilterHandler(), new SyncLockHandler(), new EarlyExpirationHandler(), new TtlHandler(), new NullValueHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -224,7 +224,7 @@ void protectionEnabled_keepsAll() { List handlers = List.of( new BloomFilterHandler(), new TtlHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -336,7 +336,7 @@ private CacheHandlerChain chainFor( List handlers = List.of(new BloomFilterHandler(), new SyncLockHandler(), new EarlyExpirationHandler(), new TtlHandler(), new NullValueHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); return factory.createChain(); } @@ -350,7 +350,7 @@ void protectionDisabled_disableNameFromAnnotation_notClassName() { List handlers = List.of( new OddlyNamedBloomHandler(), new TtlHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -365,7 +365,7 @@ void globalDisabled_disableNameFromAnnotation_notClassName() { List handlers = List.of( new WeirdlyNamedLockHandler(), new TtlHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, null, new ChainEngine()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -387,10 +387,10 @@ void createChain_withRegistry_attachesAndIncrementsFiredCounter() { when(provider.getIfAvailable()).thenReturn(registry); CacheHandler probe = new FiredCounterProbe(); - // P1-API-001-C:observer 为有序 Bean,工厂注入 List 后单一注册。 - // 4-arg 便捷构造(空 observer)不装配 registry observer — fired counter 走标准 bean。 + // P1-API-001-C:observer 为有序 Bean,工厂经主构造注入 List 后单一注册。 + // 主构造注入的 registry 驱动 fired counter observer bean。 factory = new CacheHandlerChainFactory( - List.of(probe), properties, provider, new ChainEngine(), + List.of(probe), properties, ResolvedMetrics.resolve(provider, null), new ChainEngine(), List.of(new io.github.davidhlp.spring.cache.redis.cache.FiredCounterChainObserver(registry))); CacheHandlerChain chain = factory.createChain(); diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodecTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodecTest.java index 97ae9961..e3fa961a 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodecTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodecTest.java @@ -63,6 +63,15 @@ void toValueBytes_nullValue_usesJavaSerialization() { assertThat((byte) result[0]).isEqualTo((byte) 0xAC); assertThat((byte) result[1]).isEqualTo((byte) 0xED); } + + @Test + @DisplayName("Java null 同样写出 NullValue 占位字节(不与 NullValue.INSTANCE 分叉)") + void toValueBytes_javaNull_writesNullValueBytes() { + byte[] result = codec.toValueBytes(null); + + assertThat(result).isEqualTo(codec.toValueBytes(NullValue.INSTANCE)); + assertThat(codec.fromValueBytes(result)).isSameAs(NullValue.INSTANCE); + } } @Nested diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java index fb03647d..ad8db8f9 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java @@ -57,7 +57,7 @@ class EarlyExpirationHandlerIntegrationTest extends AbstractRedisIntegrationTest private ValueOperations valueOperations; @Autowired - private NullValueEncoder nullValueEncoder; + private CacheValueCodec valueCodec; private EarlyExpirationHandler handler; private EarlyRefresh earlyRefresh; @@ -289,7 +289,7 @@ private CacheHandlerChain productionChain() { ActualCacheHandler actual = new ActualCacheHandler( redisTemplate, valueOperations, - nullValueEncoder, + valueCodec, earlyExpirationExecutor, new CacheErrorHandler()); return new CacheHandlerChain(new ChainEngine()) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java index e790495c..bedad6e2 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java @@ -530,10 +530,11 @@ void nullPutAfterLoad_rejectedAtConstruction() { } @Test - @DisplayName("可选保护依赖为 null → 合法装配,构造不抛") - void nullProtectionDeps_stillAccepted() { - assertThat(new LoaderOrchestrator(null, null, null, keyFn, checkFn, putFn)) - .isNotNull(); + @DisplayName("保护协作依赖为 null → 装配错误,构造期抛 NPE(bloomGate)") + void nullProtectionDeps_rejectedAtConstruction() { + assertThatThrownBy(() -> new LoaderOrchestrator(null, null, null, keyFn, checkFn, putFn)) + .isInstanceOf(NullPointerException.class) + .hasMessage("bloomGate"); } @Test diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheIntegrationTest.java index fb2f5b02..1dc9cb3b 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheIntegrationTest.java @@ -84,9 +84,22 @@ void setUp() { NAME, realWriter, cacheConfiguration, - ResiCacheFeatures.builder() - .meterRegistry(meterRegistry) - .build()); // bloom/sync disabled; operationResolver null + noMechanisms(meterRegistry)); // 协作对象在场;operation 无元数据 → 不启用 bloom/sync + } + + /** + * 生产形状的 feature set:元数据解析与 protection 协作对象全部在场(构造期要求非 null), + * 但无元数据 → 真实行为是「不短路、不走锁」。 + */ + private static ResiCacheFeatures noMechanisms(MeterRegistry registry) { + return ResiCacheFeatures.builder() + .meterRegistry(registry) + .operationResolver(new CacheOperationResolver( + new DefaultMethodMetadataResolver(), new RedisCacheRegister())) + .bloomGate(org.mockito.Mockito.mock(BloomGate.class)) + .syncSupport(org.mockito.Mockito.mock(SyncSupport.class)) + .syncLockTimeout(org.mockito.Mockito.mock(SyncLockTimeout.class)) + .build(); } @Nested @@ -237,6 +250,8 @@ private RedisProCache buildCacheWithBloomAndResolver( .meterRegistry(meterRegistry) .operationResolver(operationResolver) .bloomGate(new BloomGate(bloomSupport)) + .syncSupport(org.mockito.Mockito.mock(SyncSupport.class)) + .syncLockTimeout(org.mockito.Mockito.mock(SyncLockTimeout.class)) .build()); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java index 904d521c..0f118861 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoadPathTest.java @@ -31,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** @@ -144,7 +145,26 @@ private RedisProCache cacheWith( RedisCacheWriter writer, SimpleMeterRegistry registry) { return new RedisProCache(CACHE_NAME, writer, RedisCacheConfiguration.defaultCacheConfig(), - ResiCacheFeatures.builder().meterRegistry(registry).build()); + disabledMechanisms(registry)); + } + + /** + * 生产形状的 feature set:元数据解析与 protection 协作对象全部在场(构造期要求非 null), + * 但 operation 不启用 bloom/sync,故真实行为是「无元数据 → 不短路、不走锁」。 + */ + private static ResiCacheFeatures disabledMechanisms(SimpleMeterRegistry registry) { + return ResiCacheFeatures.builder() + .meterRegistry(registry) + .operationResolver(noMetadataResolver()) + .bloomGate(mock(BloomGate.class)) + .syncSupport(mock(SyncSupport.class)) + .syncLockTimeout(mock(SyncLockTimeout.class)) + .build(); + } + + /** 真实 no-metadata resolver:无激活上下文 → resolve 恒返回 null(取代「传 null 关闭解析」)。 */ + private static CacheOperationResolver noMetadataResolver() { + return new CacheOperationResolver(new DefaultMethodMetadataResolver(), new RedisCacheRegister()); } private RedisProCacheWriter writerWithPutFailure() { @@ -182,7 +202,7 @@ private RedisProCacheWriter writerWithPutFailure( statistics, valueCodec, chainFactory, - null); + noMetadataResolver()); } private void assertSinglePutFailureCounter(SimpleMeterRegistry registry) { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoaderFailurePrivacyTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoaderFailurePrivacyTest.java index 5ef009d6..2eabcd24 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoaderFailurePrivacyTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheLoaderFailurePrivacyTest.java @@ -67,7 +67,21 @@ private static RedisCacheWriter missWriter() { private RedisProCache newCache() { return new RedisProCache(CACHE_NAME, missWriter(), - RedisCacheConfiguration.defaultCacheConfig(), ResiCacheFeatures.none()); + RedisCacheConfiguration.defaultCacheConfig(), noMechanisms()); + } + + /** + * 生产形状的 feature set:协作对象在场但 operation 无元数据 → + * loader 走 default 路径(不短路、不走锁)。 + */ + private static ResiCacheFeatures noMechanisms() { + return ResiCacheFeatures.builder() + .operationResolver(new CacheOperationResolver( + new DefaultMethodMetadataResolver(), new RedisCacheRegister())) + .bloomGate(mock(BloomGate.class)) + .syncSupport(mock(SyncSupport.class)) + .syncLockTimeout(mock(SyncLockTimeout.class)) + .build(); } @Test diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheManagerTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheManagerTest.java index 3b0db5e8..70b37e5e 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheManagerTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheManagerTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -51,7 +52,12 @@ void setUp() { defaultConfiguration, ResiCacheFeatures.builder() .meterRegistry(meterRegistry) - .build(), // bloom/operationResolver/sync disabled + .operationResolver(new CacheOperationResolver( + new DefaultMethodMetadataResolver(), new RedisCacheRegister())) + .bloomGate(mock(BloomGate.class)) + .syncSupport(mock(SyncSupport.class)) + .syncLockTimeout(mock(SyncLockTimeout.class)) + .build(), // 生产形状:协作对象在场;无元数据 → 不启用 bloom/sync Collections.emptyMap(), false); // transactionAware } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterFailureTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterFailureTest.java index 632dc2a9..d1c7cfe9 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterFailureTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterFailureTest.java @@ -22,6 +22,9 @@ @ExtendWith(MockitoExtension.class) class RedisProCacheWriterFailureTest { + /** 真实 no-metadata resolver(无激活上下文 → resolve 恒 null),取代旧的「传 null 关闭解析」。 */ + private static final CacheOperationResolver NO_METADATA_RESOLVER = + new CacheOperationResolver(new DefaultMethodMetadataResolver(), new RedisCacheRegister()); @Mock private CacheStatisticsCollector statistics; @@ -42,7 +45,7 @@ class RedisProCacheWriterFailureTest { void setUp() { when(chainFactory.createChain()).thenReturn(chain); writer = new RedisProCacheWriter( - statistics, valueCodec, chainFactory, null); + statistics, valueCodec, chainFactory, NO_METADATA_RESOLVER); } @Test diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterStatisticsTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterStatisticsTest.java index 2c5042fc..d2f4f477 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterStatisticsTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterStatisticsTest.java @@ -22,6 +22,10 @@ class RedisProCacheWriterStatisticsTest { private static final byte[] KEY = "stats-cache::key".getBytes(); private static final byte[] VALUE = "value".getBytes(); + /** 真实 no-metadata resolver(无激活上下文 → resolve 恒 null),取代旧的「传 null 关闭解析」。 */ + private static final CacheOperationResolver NO_METADATA_RESOLVER = + new CacheOperationResolver(new DefaultMethodMetadataResolver(), new RedisCacheRegister()); + private CacheStatisticsCollector oldCollector; @@ -41,14 +45,14 @@ void setUp() { oldCollector = CacheStatisticsCollector.create(); when(chainFactory.createChain()).thenReturn(chain); writer = new RedisProCacheWriter( - oldCollector, valueCodec, chainFactory, null); + oldCollector, valueCodec, chainFactory, NO_METADATA_RESOLVER); } @Test void writerOperations_recordSpringStatisticsAtOperationBoundary() { CacheStatisticsCollector collector = CacheStatisticsCollector.create(); writer = new RedisProCacheWriter( - collector, valueCodec, chainFactory, null); + collector, valueCodec, chainFactory, NO_METADATA_RESOLVER); when(chain.execute(any())).thenReturn(CacheResult.miss()); assertThat(writer.get(CACHE_NAME, KEY)).isNull(); From ece301f427e48512cf7233f26b4dd12fbb66b079 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 09:06:50 +0800 Subject: [PATCH 16/56] test(cache): remove protocol and encoder tests for deleted branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NullValueEncoderTest exercised the wrapper deleted last commit; its null-vs-NullValue decision is now covered by CacheValueCodecTest. HandlerResultProtocolTest only asserted ChainEngine's null-decision re-check, which is unreachable by construction (HandlerResult's record constructor rejects a null decision; the test reached it solely by mocking a final record). shouldTerminate() coverage is unaffected — HandlerResult and its existing TtlHandler/BloomFilter/ActualCacheHandler tests are unchanged. --- .../cache/HandlerResultProtocolTest.java | 42 --------- .../redis/cache/NullValueEncoderTest.java | 92 ------------------- 2 files changed, 134 deletions(-) delete mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java delete mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoderTest.java diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java deleted file mode 100644 index 57d04f7c..00000000 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.github.davidhlp.spring.cache.redis.cache; - -import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; -import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; -import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; -import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * HandlerResult 与 ChainEngine 之间的 SPI 协议边界测试。 - */ -@DisplayName("HandlerResult Protocol Tests") -class HandlerResultProtocolTest { - - @Test - @DisplayName("引擎拒绝 null decision 并指名违规 handler") - void engine_rejectsNullDecisionWithNamedHandler() { - HandlerResult invalidResult = mock(HandlerResult.class); - when(invalidResult.decision()).thenReturn(null); - CacheHandler offendingHandler = context -> invalidResult; - - assertThatThrownBy(() -> new ChainEngine() - .execute(List.of(offendingHandler), testContext())) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("null decision") - .hasMessageContaining(offendingHandler.getClass().getName()); - } - - private static CacheContext testContext() { - return CacheContext.of(CacheInput.builder() - .operation(CacheOperation.GET) - .cacheName("test-cache") - .redisKey("test:key") - .actualKey("test:key") - .build()); - } -} diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoderTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoderTest.java deleted file mode 100644 index f2a18c60..00000000 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/NullValueEncoderTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package io.github.davidhlp.spring.cache.redis.cache; - - - - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.cache.support.NullValue; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * NullValueEncoder 单元测试 — null decision 与 value codec 的 contract。 - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("NullValueEncoder Tests") -class NullValueEncoderTest { - - @Mock - private CacheValueCodec valueCodec; - - private NullValueEncoder encoder; - - @BeforeEach - void setUp() { - encoder = new NullValueEncoder(valueCodec); - } - - @Nested - @DisplayName("encodeForReturn() Tests") - class EncodeForReturnTests { - - @Test - @DisplayName("encodes null value as NullValue.INSTANCE bytes") - void encodeForReturn_nullValue_serializesNullValue() { - byte[] expectedBytes = new byte[]{1, 2, 3}; - when(valueCodec.toValueBytes(NullValue.INSTANCE)).thenReturn(expectedBytes); - - byte[] result = encoder.encodeForReturn(null, "test-cache", "key"); - - assertThat(result).isEqualTo(expectedBytes); - verify(valueCodec).toValueBytes(NullValue.INSTANCE); - verify(valueCodec, never()).toValueBytes((Object) null); - } - - @Test - @DisplayName("passes non-null value directly to CacheValueCodec") - void encodeForReturn_nonNullValue_serializesValue() { - Object value = "test-value"; - byte[] expectedBytes = new byte[]{4, 5, 6}; - when(valueCodec.toValueBytes(value)).thenReturn(expectedBytes); - - byte[] result = encoder.encodeForReturn(value, "test-cache", "key"); - - assertThat(result).isEqualTo(expectedBytes); - verify(valueCodec).toValueBytes(value); - verify(valueCodec, never()).toValueBytes(NullValue.INSTANCE); - } - - @Test - @DisplayName("passes NullValue.INSTANCE through to CacheValueCodec as-is") - void encodeForReturn_nullValueInstance_serializesNullValue() { - byte[] expectedBytes = new byte[]{7, 8, 9}; - when(valueCodec.toValueBytes(NullValue.INSTANCE)).thenReturn(expectedBytes); - - byte[] result = encoder.encodeForReturn(NullValue.INSTANCE, "test-cache", "key"); - - assertThat(result).isEqualTo(expectedBytes); - verify(valueCodec).toValueBytes(NullValue.INSTANCE); - } - - @Test - @DisplayName("returns bytes for arbitrary object types (Integer, Map, etc.)") - void encodeForReturn_arbitraryType_serializesValue() { - Object value = 42; - byte[] expectedBytes = new byte[]{10, 20, 30}; - when(valueCodec.toValueBytes(value)).thenReturn(expectedBytes); - - byte[] result = encoder.encodeForReturn(value, "test-cache", "key"); - - assertThat(result).isEqualTo(expectedBytes); - verify(valueCodec).toValueBytes(value); - } - } -} From db8f8631ba383125eb4257d4eb44ea3fb8b03bad Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 09:42:59 +0800 Subject: [PATCH 17/56] refactor(cache): own observer order on the observer class, not the bean method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The factory's observerOrder() reads the class-level @Order, but @Order lived on the four @Bean methods, so the sort was a no-op and the "registration (@Order) order" STABILITY.md §4 promises was only incidentally satisfied by injection order. Move @Order(1..4) onto MDCStamp/ChainDebugLog/ChainTimer/ FiredCounter observer classes so the factory sort is the single declaration actually in force, and correct the factory/config Javadoc that claimed Spring pre-sorts the injected list. --- .../redis/cache/CacheHandlerChainFactory.java | 14 +++++++++++--- .../redis/cache/ChainDebugLogChainObserver.java | 2 ++ .../cache/redis/cache/ChainTimerChainObserver.java | 2 ++ .../redis/cache/FiredCounterChainObserver.java | 2 ++ .../cache/redis/cache/MDCStampChainObserver.java | 5 +++++ .../redis/cache/RedisProCacheConfiguration.java | 11 ++++------- 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java index 34131187..0279d8be 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java @@ -156,7 +156,8 @@ public CacheHandlerChain createChain() { return cachedChain; } - // 1) 装配 observer(单一装配点):注入列表已由 Spring 按 @Order 排序。 + // 1) 装配 observer(单一装配点):由本工厂按 observer 类级 @Order 排序后注册, + // 不依赖 Spring 注入列表的顺序(注入顺序非本类的顺序契约)。 // idempotent 由本方法的单例缓存 miss pattern 保证,首次 miss 后不会再进本块。 registerObserversOnce(); @@ -205,9 +206,12 @@ public CacheHandlerChain createChain() { /** * 注册注入的 observer 到 Engine — 单一装配点。 * - *

      注入列表由 Spring 按 {@code @Order} 升序提供;此处按类型去重 + *

      顺序由 observer 类级 {@code @Order} 单一拥有,本方法按 {@link #observerOrder} + * 升序排序(标准 MDC→DebugLog→Timer→FiredCounter 各带 {@code @Order(1..4)}; + * 未标注的自定义 observer 取 {@link Integer#MAX_VALUE} 排在最后),再按类型去重 * (同名同 tag counter 重复注册幂等,但 observer 实例重复注册会双计 — 去重保证 - * 每个 observer 类恰好注册一次),随后按序 addObserver。 + * 每个 observer 类恰好注册一次),随后按序 addObserver。注册顺序即 + * {@code STABILITY.md §4} 承诺的 observer 执行顺序。 * *

      registry 缺失时:MDC/DebugLog 无 registry 依赖;Timer/FiredCounter * observer 内部 lazy 检测,registry 缺失时全 no-op。 @@ -226,6 +230,10 @@ private void registerObserversOnce() { } } + /** + * observer 顺序的唯一真值读取点:读 observer 类上的 {@code @Order}(而非 {@code @Bean} + * 方法上的),因此 {@link #registerObserversOnce} 的排序对标准与自定义 observer 均生效。 + */ private int observerOrder(ChainObserver observer) { org.springframework.core.annotation.Order order = observer.getClass().getAnnotation(org.springframework.core.annotation.Order.class); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java index 07b6cf1c..63404c32 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java @@ -10,6 +10,7 @@ import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; +import org.springframework.core.annotation.Order; /** * ChainObserver 的 perNode 实现 — 每个被引擎求值的 handler 在 afterNode 阶段 @@ -30,6 +31,7 @@ * beforeNode → handler → afterNode),无共享状态。 */ @Slf4j +@Order(2) // 执行顺序单一真值源=类级 @Order,见 MDCStampChainObserver 注释 final class ChainDebugLogChainObserver implements ChainObserver { @Override diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java index 5bbc9809..2f12d664 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java @@ -14,6 +14,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; +import org.springframework.core.annotation.Order; /** * 责任链节点级 Micrometer Timer。 @@ -30,6 +31,7 @@ *

      线程安全:Timer map 支持并发注册;{@link TimerScope} 是单次节点调用的不可变 * token,不在 observer 内保存共享的 per-call 状态。registry 缺失时全程 no-op。 */ +@Order(3) // 执行顺序单一真值源=类级 @Order,见 MDCStampChainObserver 注释 final class ChainTimerChainObserver implements ChainObserver { static final String METRIC_NAME = "resicache.chain.execute"; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java index f1f9227e..34948cce 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java @@ -13,6 +13,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; /** * ChainObserver 的 perNode 实现 — per-handler uniform {@code resicache.handler.fired} @@ -32,6 +33,7 @@ * handler 类型竞争同 observer);{@link Counter#increment()} 自身线程安全。 */ @Slf4j +@Order(4) // 执行顺序单一真值源=类级 @Order,见 MDCStampChainObserver 注释 final class FiredCounterChainObserver implements ChainObserver { private final MeterRegistry registry; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java index d87c0012..5c1276db 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java @@ -11,6 +11,7 @@ import java.util.concurrent.ThreadLocalRandom; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; +import org.springframework.core.annotation.Order; /** * ChainObserver 的 aroundChain 实现 — 在链入口为本次执行 stamp 唯一 requestId @@ -34,6 +35,10 @@ * snapshot/restore 配对,无共享状态。 */ @Slf4j +// 标准 observer 执行顺序由类级 @Order 单一拥有(MDC→DebugLog→Timer→FiredCounter): +// 工厂 CacheHandlerChainFactory#observerOrder 读此注解排序,故本 observer 先 stamp requestId, +// ChainDebugLogChainObserver 才能在 afterNode 读到 MDC 中的 id。 +@Order(1) final class MDCStampChainObserver implements ChainObserver { @Override diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java index c1667d30..d2cb6707 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java @@ -41,24 +41,22 @@ class RedisProCacheConfiguration { * 标准 ChainObserver beans — P1-API-001-C:标准和用户 observer 均为有序 Bean, * 由 {@link CacheHandlerChainFactory} 单一装配点注入 Engine。 * - *

      顺序(MDC → DebugLog → Timer → FiredCounter)由 {@code @Order} 显式声明: - * MDC 先 stamp,DEBUG log 再读 requestId,Timer/FiredCounter 最后打点。 - * registry 缺失时 Timer/FiredCounter observer 内部 no-op。 + *

      执行顺序(MDC → DebugLog → Timer → FiredCounter)由 observer 类自身的 + * {@code @Order} 单一声明(见各 observer 类);工厂 {@code observerOrder} 读取该 + * 类级注解排序,故 bean 方法不再重复声明。MDC 先 stamp,DEBUG log 再读 requestId, + * Timer/FiredCounter 最后打点。registry 缺失时 Timer/FiredCounter observer 内部 no-op。 */ @Bean - @org.springframework.core.annotation.Order(1) public io.github.davidhlp.spring.cache.redis.cache.MDCStampChainObserver mdcStampChainObserver() { return new io.github.davidhlp.spring.cache.redis.cache.MDCStampChainObserver(); } @Bean - @org.springframework.core.annotation.Order(2) public io.github.davidhlp.spring.cache.redis.cache.ChainDebugLogChainObserver chainDebugLogChainObserver() { return new io.github.davidhlp.spring.cache.redis.cache.ChainDebugLogChainObserver(); } @Bean - @org.springframework.core.annotation.Order(3) public io.github.davidhlp.spring.cache.redis.cache.ChainTimerChainObserver chainTimerChainObserver( ResolvedMetrics resolvedMetrics) { return new io.github.davidhlp.spring.cache.redis.cache.ChainTimerChainObserver( @@ -66,7 +64,6 @@ public io.github.davidhlp.spring.cache.redis.cache.ChainTimerChainObserver chain } @Bean - @org.springframework.core.annotation.Order(4) public io.github.davidhlp.spring.cache.redis.cache.FiredCounterChainObserver firedCounterChainObserver( ResolvedMetrics resolvedMetrics) { return new io.github.davidhlp.spring.cache.redis.cache.FiredCounterChainObserver( From acfff0c56d157bc25c437a5e9fdf7f2da38b6820 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 09:46:28 +0800 Subject: [PATCH 18/56] test(cache): pin observer dispatch order to class-level @Order Add a behavioral ChainObserverTest case that injects three @Order observers in reverse order through the real factory and asserts they fire in @Order order (hook sequence via observable effect, no reflection). Update RedisProCacheConfigurationContractTest to assert @Order on the observer classes (the location the factory reads) instead of the inert bean methods. --- .../cache/redis/cache/ChainObserverTest.java | 71 +++++++++++++++++++ ...edisProCacheConfigurationContractTest.java | 31 ++++---- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index bf89176f..044c1e4b 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -10,17 +10,22 @@ import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; +import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.slf4j.MDC; +import org.springframework.core.annotation.Order; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * ChainObserver 实现测试 — 4 个标准 observer 的契约。 @@ -253,4 +258,70 @@ void afterNode_incrementsPerHandlerType() { assertThat((double) counter.count()).isEqualTo(3.0); } } + + @Nested + @DisplayName("Observer order") + class ObserverOrderTests { + + /** + * 工厂按 observer 类级 {@code @Order} 注册,Engine 依注册序派发每个 hook。故意以 + * 逆序注入,证明生效顺序来自 @Order 而非注入顺序:若排序退化为 no-op(注解不在类上), + * beforeNode 将以 [third, first, second] 触发,断言失败。 + */ + @Test + @DisplayName("observers dispatch in class-level @Order order across a chain run") + void createChain_dispatchesByOrderNotInjectionOrder() { + RedisProCacheProperties properties = mock(RedisProCacheProperties.class); + List sequence = new ArrayList<>(); + List injected = List.of( + new ThirdOrderObserver(sequence), + new FirstOrderObserver(sequence), + new SecondOrderObserver(sequence)); + + CacheHandlerChain chain = new CacheHandlerChainFactory( + List.of(new SingleNodeHandler()), properties, + ResolvedMetrics.resolve(null, null), new ChainEngine(), injected) + .createChain(); + chain.execute(ctx); + + assertThat(sequence).containsExactly("first", "second", "third"); + } + + private static final class SingleNodeHandler implements CacheHandler { + @Override + public HandlerResult handle(CacheContext context) { + return HandlerResult.continueChain(); + } + } + + @Order(1) + private static final class FirstOrderObserver implements ChainObserver { + private final List sequence; + FirstOrderObserver(List sequence) { this.sequence = sequence; } + @Override + public void beforeNode(CacheHandler handler, CacheContext context) { + sequence.add("first"); + } + } + + @Order(2) + private static final class SecondOrderObserver implements ChainObserver { + private final List sequence; + SecondOrderObserver(List sequence) { this.sequence = sequence; } + @Override + public void beforeNode(CacheHandler handler, CacheContext context) { + sequence.add("second"); + } + } + + @Order(3) + private static final class ThirdOrderObserver implements ChainObserver { + private final List sequence; + ThirdOrderObserver(List sequence) { this.sequence = sequence; } + @Override + public void beforeNode(CacheHandler handler, CacheContext context) { + sequence.add("third"); + } + } + } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index 4831597d..61c005c6 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -321,7 +321,7 @@ String unrelatedHostBean() { } @Test - void standardObserverBeans_areDeclaredWithOrder() { + void standardObservers_declareOrderOnTheirClass() { var observerMethods = java.util.Arrays.stream( RedisProCacheConfiguration.class.getDeclaredMethods()) .filter(method -> io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver.class @@ -329,16 +329,23 @@ void standardObserverBeans_areDeclaredWithOrder() { .toList(); assertThat(observerMethods).hasSize(4); - assertThat(observerMethods).allSatisfy(method -> { - assertThat(method.getAnnotation(Bean.class)) - .as("observer factory must be a bean method") - .isNotNull(); - assertThat(method.getAnnotation(org.springframework.core.annotation.Order.class)) - .as("observer bean must be ordered") - .isNotNull(); - }); - assertThat(observerMethods) - .extracting(method -> method.getAnnotation( + assertThat(observerMethods).allSatisfy(method -> + assertThat(method.getAnnotation(Bean.class)) + .as("observer factory must be a bean method") + .isNotNull()); + + // 顺序契约必须落在工厂 CacheHandlerChainFactory#observerOrder 真正读取的那一处 —— + // observer 类级 @Order,而非 @Bean 方法上的注解(工厂不看方法注解)。 + var observerClasses = observerMethods.stream() + .map(java.lang.reflect.Method::getReturnType) + .toList(); + assertThat(observerClasses).allSatisfy(observerClass -> + assertThat(observerClass.getAnnotation( + org.springframework.core.annotation.Order.class)) + .as("%s must carry class-level @Order", observerClass.getSimpleName()) + .isNotNull()); + assertThat(observerClasses) + .extracting(c -> c.getAnnotation( org.springframework.core.annotation.Order.class).value()) .containsExactlyInAnyOrder(1, 2, 3, 4); } @@ -357,7 +364,7 @@ void everyDefaultBeanDeclaresBackoff() { assertThat(beanMethods).isNotEmpty(); // 标准 observer 是叠加钩子(用户 observer 与它们共存),不是可替换默认 bean; - // 该集合由 standardObserverBeans_areDeclaredWithOrder 固定为 4 个。 + // 该集合由 standardObservers_declareOrderOnTheirClass 固定为 4 个。 // 其余每个 @Bean 方法都必须按类型 back off —— 新增服务 bean 缺少注解除即失败。 assertThat(beanMethods) .filteredOn(method -> !io.github.davidhlp.spring.cache.redis.chain.observer From b0d96f924613f9e77ac375857d0f3cbccb657cab Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:07:04 +0800 Subject: [PATCH 19/56] fix(cache): bind required collaborators in LoaderOrchestrator production-surface test c7 made LoaderOrchestrator's constructor require bloomGate/syncSupport/syncLockTimeout (unreachable null branches deleted). boundCallbacks_productionEntryUsesOnlyLoaderAndKey still passed nulls and now NPEs. Reuse the migrated bound() helper (real no-op collaborators) so the case keeps its intent: production orchestrate crosses only (loader, key). Assertions unchanged. --- .../spring/cache/redis/cache/LoaderOrchestratorTest.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java index bedad6e2..09375e8d 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/LoaderOrchestratorTest.java @@ -368,13 +368,7 @@ class DefaultLoadPathTests { @Test @DisplayName("bound callback constructor exposes a small production call surface") void boundCallbacks_productionEntryUsesOnlyLoaderAndKey() { - LoaderOrchestrator boundOrchestrator = new LoaderOrchestrator( - null, - null, - null, - key -> testRedisKey, - key -> null, - (key, value) -> { }); + LoaderOrchestrator boundOrchestrator = bound(key -> null, (key, value) -> { }); LoadOutcome outcome = boundOrchestrator.orchestrate( "testCache", () -> "loaded-value", "key1", operation(false, false)); From 5b9070a734a5877ce4b5b14a07f9b0f5f5e9ec44 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:13:02 +0800 Subject: [PATCH 20/56] fix(cache): normalize blank unless on the policy face to match the AOP face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c6 made AOP and policy operations derive from one RedisCacheAttributes projection so the two faces cannot disagree, but AnnotationAopBehaviorMatrixTest. bothFacesComeFromOneProjection failed: for an unset `unless` the policy builder set the raw "" while the AOP builder (and SpringCacheableAdapter) guard text fields with hasText and leave it null — two representations from one projection. Route the policy applyTo(Cacheable/Put) `unless` through BuilderPopulator.applyText, the same seam the AOP face and SpringAnnotationAdapter already use, so a blank unless is null on both faces. Real values are unaffected. --- .../spring/cache/redis/cache/RedisCacheAttributes.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java index cd8ee253..1d0f70e3 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributes.java @@ -197,8 +197,10 @@ public RedisCacheableOperation.Builder applyTo(RedisCacheableOperation.Builder b // sink 列表仅此一份,三个 applyTo 重载共享,漂移由 RedisCacheAttributeSink 拦截。 populate(b, this, COMMON_SINKS); // Cacheable-only 5 字段:builder-only,不出现在其他两个 applyTo 重载 + // unless 与 AOP 面同样走 hasText 守卫:空串两面统一为 null,同一投影不再派生出 + // 不同表示(policy 曾存 "" 而 AOP 存 null)。 + BuilderPopulator.applyText(b, unless, RedisCacheableOperation.Builder::unless); return b - .unless(unless) .type(type) .cacheNullValues(cacheNullValues) .randomTtl(randomTtl) @@ -223,8 +225,9 @@ public RedisCachePutOperation.Builder applyTo(RedisCachePutOperation.Builder b) // 14 共享字段填充走本类 COMMON_SINKS 单一真相 populate(b, this, COMMON_SINKS); // Put-only 5 字段(与 Cacheable 同集): + // unless 与 AOP 面同样走 hasText 守卫,空串两面统一为 null(见 Cacheable 重载注释)。 + BuilderPopulator.applyText(b, unless, RedisCachePutOperation.Builder::unless); return b - .unless(unless) .type(type) .cacheNullValues(cacheNullValues) .randomTtl(randomTtl) From 9e4c2fdc254e8927c4555a1b16053207a1c380d8 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:03:34 +0800 Subject: [PATCH 21/56] refactor(cache): resolve metrics opt-in at one non-null seam resi-cache.metrics.enabled is now read in exactly one place (ResolvedMetrics.resolve), and a null Environment no longer skips the opt-in check, so test and production assembly cross the same rule. The seam is never null: the disabled case is a shared no-op CompositeMeterRegistry, which removes the per-caller registry null re-checks in the chain factory, the timer/fired observers, the failure reporter and the migration engine. RedisCacheHealthIndicator no longer borrows the metrics switch as its activation gate and drops the properties field it never read; its health details and ordering are unchanged. --- .../redis/cache/CacheFailureReporter.java | 6 ++-- .../redis/cache/CacheHandlerChainFactory.java | 2 +- .../redis/cache/ChainTimerChainObserver.java | 7 +++-- .../cache/FiredCounterChainObserver.java | 6 ++-- .../cache/RedisCacheHealthIndicator.java | 11 +++---- .../cache/RedisProCacheConfiguration.java | 19 +++++------- .../cache/redis/cache/ResolvedMetrics.java | 29 ++++++++++++------- .../cache/SerializationMigrationEngine.java | 6 ++-- ...itional-spring-configuration-metadata.json | 6 ++++ 9 files changed, 47 insertions(+), 45 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java index af1b2111..346f2240 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java @@ -30,7 +30,8 @@ * 不属于本指标 — fail-open 是成功的保护行为而非缓存失败,误报会污染降级告警。 * 二者刻意分离:filter 级 telemetry 在 adapter,缓存级失败在本 reporter。 * - *

      registry 缺失(null)时全程 no-op,与 ResiCache 其余 metrics 行为一致。 + *

      registry 由 {@link ResolvedMetrics} 单一决议,永不为 null;metrics 未启用时它是 + * no-op seam,本 reporter 全程无副作用,与 ResiCache 其余 metrics 行为一致。 * counter map 按 (operation, kind, strategy) 组合惰性注册 — tag 组合有界 * (5 ops × 5 kinds × 3 strategies),cardinality 可控。 */ @@ -56,9 +57,6 @@ public CacheFailureReporter(MeterRegistry registry) { public void report(@org.springframework.lang.Nullable CacheOperation operation, @org.springframework.lang.Nullable FailureKind kind, @org.springframework.lang.Nullable ErrorStrategy strategy) { - if (registry == null) { - return; - } FailureKey key = new FailureKey( operation == null ? "UNKNOWN" : operation.name(), kind == null ? "UNKNOWN" : kind.name(), diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java index 34131187..bd7e206f 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java @@ -186,7 +186,7 @@ public CacheHandlerChain createChain() { } chain.addHandler(handler); - if (registry != null && handler instanceof AbstractCacheHandler ach) { + if (handler instanceof AbstractCacheHandler ach) { ach.attachMeterRegistry(registry); } log.debug("Added handler to chain: {} (order={})", diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java index 5bbc9809..4ebb7cee 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java @@ -28,7 +28,8 @@ * handler 类型、三值 decision 与应用配置的 cacheName。 * *

      线程安全:Timer map 支持并发注册;{@link TimerScope} 是单次节点调用的不可变 - * token,不在 observer 内保存共享的 per-call 状态。registry 缺失时全程 no-op。 + * token,不在 observer 内保存共享的 per-call 状态。registry 由 {@link ResolvedMetrics} + * 单一决议、永不为 null;metrics 未启用时它是 no-op seam,计时样本不落任何出口。 */ final class ChainTimerChainObserver implements ChainObserver { @@ -43,13 +44,13 @@ public ChainTimerChainObserver(MeterRegistry registry) { @Override public Object onNodeStart(CacheHandler handler, CacheContext context) { - return registry == null ? null : new TimerScope(System.nanoTime()); + return new TimerScope(System.nanoTime()); } @Override public void onNodeEnd(CacheHandler handler, CacheContext context, Object scopeToken, HandlerResult result) { - if (registry == null || result == null || !(scopeToken instanceof TimerScope scope)) { + if (result == null || !(scopeToken instanceof TimerScope scope)) { return; } TimerKey key = new TimerKey( diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java index f1f9227e..c7ae8ce8 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java @@ -23,7 +23,8 @@ * 注册钩子({@code onAttachMetrics})。 * *

      disabled handler 语义 counter 不注册;fired 与语义 - * counter 都在 handler 进链时统一注册。registry 缺失时本 observer 全 no-op。 + * counter 都在 handler 进链时统一注册。registry 由 {@link ResolvedMetrics} 单一决议、 + * 永不为 null;metrics 未启用时它是 no-op seam,本 observer 无副作用。 * *

      per-handler span child 可在本类的 {@code afterNode} 内挂载, * 零修改 Engine 即可与本 counter 同步打点。 @@ -54,9 +55,6 @@ public Object onChainStart(CacheContext context) { @Override public void afterNode(CacheHandler handler, CacheContext context, io.github.davidhlp.spring.cache.redis.chain.HandlerResult result) { - if (registry == null) { - return; - } String handlerTag = CacheHandlerChain.handlerTag(handler); Counter counter = firedCounters.computeIfAbsent(handler.getClass(), klass -> Counter.builder("resicache.handler.fired") diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java index acd81535..e03a7a3a 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java @@ -3,11 +3,9 @@ -import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.health.contributor.Health; import org.springframework.boot.health.contributor.HealthIndicator; import org.springframework.data.redis.core.RedisCallback; @@ -24,24 +22,23 @@ * 报告 {@code protection.degraded=local-only}(不阻断整体 UP 状态, * 但暴露安全降级便于运维感知)。本类标 {@code Status.UP} + detail 记录 *

    + * + *

    本指标报告的是 Redis 连通性与 protection 降级,与 metrics 无关,故只按 + * Actuator 是否在 classpath 上启用(见 {@code @ConditionalOnClass})。 */ @Slf4j @Component @ConditionalOnClass(HealthIndicator.class) -@ConditionalOnProperty(prefix = "resi-cache.metrics", name = "enabled", havingValue = "true", matchIfMissing = false) class RedisCacheHealthIndicator implements HealthIndicator { private final RedisTemplate redisCacheTemplate; private final SyncSupport syncSupport; - private final RedisProCacheProperties properties; public RedisCacheHealthIndicator(RedisTemplate redisCacheTemplate, - ObjectProvider syncSupportProvider, - ObjectProvider propertiesProvider) { + ObjectProvider syncSupportProvider) { this.redisCacheTemplate = redisCacheTemplate; // ObjectProvider null-safe:无 Redisson + 无 sync 配置时 SyncSupport 可能不存在 this.syncSupport = syncSupportProvider.getIfAvailable(); - this.properties = propertiesProvider.getIfAvailable(); } @Override diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java index c1667d30..2ae1d146 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java @@ -7,7 +7,6 @@ import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import io.github.davidhlp.spring.cache.redis.protection.bloom.filter.BloomIFilter; -import io.micrometer.core.instrument.MeterRegistry; import java.time.Clock; import java.util.HashMap; import java.util.Map; @@ -43,7 +42,8 @@ class RedisProCacheConfiguration { * *

    顺序(MDC → DebugLog → Timer → FiredCounter)由 {@code @Order} 显式声明: * MDC 先 stamp,DEBUG log 再读 requestId,Timer/FiredCounter 最后打点。 - * registry 缺失时 Timer/FiredCounter observer 内部 no-op。 + * registry 由 {@link ResolvedMetrics} 单一决议;metrics 未启用时它是 no-op seam, + * Timer/FiredCounter observer 照常装配。 */ @Bean @org.springframework.core.annotation.Order(1) @@ -82,11 +82,11 @@ public MethodMetadataResolver methodMetadataResolver() { @Bean @ConditionalOnMissingBean(CacheErrorHandler.class) public CacheErrorHandler cacheErrorHandler(ResolvedMetrics resolvedMetrics) { - MeterRegistry registry = resolvedMetrics.meterRegistry(); - // Failure-metrics contract:统一失败指标 reporter(registry 缺失 → 内部 no-op) + // Failure-metrics contract:统一失败指标 reporter;metrics 未启用时 + // ResolvedMetrics 交出 no-op seam,此处不再按 null 分支。 return new CacheErrorHandler( - registry == null ? null - : new io.github.davidhlp.spring.cache.redis.cache.CacheFailureReporter(registry)); + new io.github.davidhlp.spring.cache.redis.cache.CacheFailureReporter( + resolvedMetrics.meterRegistry())); } @Bean @@ -181,13 +181,8 @@ public RedisProCacheManager cacheManager( Map initialCacheConfigurations = buildInitialCacheConfigurations(properties, defaultRedisCacheConfiguration); - MeterRegistry meterRegistry = resolvedMetrics.meterRegistry(); - if (meterRegistry == null) { - log.debug("MeterRegistry not available or metrics disabled — metrics will be disabled"); - } - ResiCacheFeatures features = ResiCacheFeatures.builder() - .meterRegistry(meterRegistry) + .meterRegistry(resolvedMetrics.meterRegistry()) .bloomGate(bloomGate) .operationResolver(operationResolver) .syncSupport(syncSupport) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java index a87595a6..80bb32b2 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java @@ -1,27 +1,36 @@ package io.github.davidhlp.spring.cache.redis.cache; import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; import org.springframework.beans.factory.ObjectProvider; import org.springframework.core.env.Environment; import org.springframework.lang.Nullable; /** - * Resolved opt-in metrics choice shared by the normal runtime assembly. + * Single owner of the "are metrics enabled" decision: the opt-in key is read here, + * once, so assembly and tests cross the same check. * - * @param meterRegistry the enabled registry, or {@code null} when metrics are disabled/unavailable + *

    Callers always get a non-null seam. When metrics are disabled, or the application has + * no {@code MeterRegistry} bean, the seam is the shared no-op adapter + * {@link #NOOP_REGISTRY} — a {@link CompositeMeterRegistry} that never gets a child + * registry, so every recorded sample lands nowhere. The disabled case is therefore not a + * {@code registry == null} re-check at each caller. + * + * @param meterRegistry metrics seam, never {@code null} */ -record ResolvedMetrics(@Nullable MeterRegistry meterRegistry) { +record ResolvedMetrics(MeterRegistry meterRegistry) { + + /** No-op metrics seam used when metrics are disabled or unavailable. */ + static final MeterRegistry NOOP_REGISTRY = new CompositeMeterRegistry(); private static final String METRICS_ENABLED_PROPERTY = "resi-cache.metrics.enabled"; static ResolvedMetrics resolve( @Nullable ObjectProvider meterRegistryProvider, - @Nullable Environment environment) { - if (environment != null - && !environment.getProperty(METRICS_ENABLED_PROPERTY, Boolean.class, false)) { - return new ResolvedMetrics(null); - } - return new ResolvedMetrics( - meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable()); + Environment environment) { + MeterRegistry registry = + meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable(); + boolean enabled = environment.getProperty(METRICS_ENABLED_PROPERTY, Boolean.class, false); + return new ResolvedMetrics(enabled && registry != null ? registry : NOOP_REGISTRY); } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java index 2e066ca1..1d46e1b8 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java @@ -288,10 +288,8 @@ private void validateSettings() { } private void record(String outcome) { - if (meterRegistry != null) { - meterRegistry.counter(METRIC_NAME, - "phase", migration.getPhase().name(), "outcome", outcome).increment(); - } + meterRegistry.counter(METRIC_NAME, + "phase", migration.getPhase().name(), "outcome", outcome).increment(); } private static byte[] appendSuffix(byte[] key, String suffix) { diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json index daee1f69..58e606a1 100644 --- a/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -23,6 +23,12 @@ "type": "java.lang.Integer", "defaultValue": 10000, "description": "Maximum cached Bloom hash positions." + }, + { + "name": "resi-cache.metrics.enabled", + "type": "java.lang.Boolean", + "defaultValue": false, + "description": "Opt-in switch for cache metrics; also requires an application MeterRegistry bean." } ] } From 66b807cbc867bda12b8f1ed98460bb3f35c70135 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:04:27 +0800 Subject: [PATCH 22/56] test(cache): drive the metrics seam through the same resolve call Factory tests no longer pass a null Environment to bypass the opt-in: they resolve the seam with an explicit Environment (disabled by default, enabled where the fired counter is asserted). Observer and reporter tests hand out the shared no-op seam instead of null, and the assembly contract now asserts the disabled case is that seam. The health indicator test follows its two-argument constructor. --- .../redis/cache/CacheFailureReporterTest.java | 10 +++--- .../cache/CacheHandlerChainFactoryTest.java | 36 ++++++++++--------- .../cache/redis/cache/ChainObserverTest.java | 11 +++--- .../cache/RedisCacheHealthIndicatorTest.java | 7 ++-- ...edisProCacheConfigurationContractTest.java | 2 +- 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java index 40fd413e..7c5ea887 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java @@ -24,7 +24,7 @@ *

  6. 唯一指标 {@code resicache.cache.failure},tag 仅 operation/kind/strategy * (有限枚举低基数,无 cacheName/key/message)
  7. *
  8. 同 (op,kind,strategy) 多次上报 → 同一 counter 累加(非每事件新 counter)
  9. - *
  10. registry 缺失 → no-op 不抛
  11. + *
  12. metrics 未启用(no-op seam)→ 不向应用 registry 注册任何 meter,也不抛
  13. * */ @DisplayName("CacheFailureReporter Tests") @@ -82,11 +82,11 @@ void report_nullArgs_usesUnknownTag() { } @Test - @DisplayName("registry 缺失 → no-op 不抛") - void nullRegistry_noOp() { - CacheFailureReporter noRegistry = new CacheFailureReporter(null); + @DisplayName("no-op seam → 不抛异常,且不向应用 registry 注册任何 meter") + void noopRegistry_noOp() { + CacheFailureReporter noRegistry = new CacheFailureReporter(ResolvedMetrics.NOOP_REGISTRY); noRegistry.report(CacheOperation.PUT, FailureKind.REDIS, ErrorStrategy.FAIL_FAST); - // 不抛即通过 + assertThat(registry.getMeters()).isEmpty(); } @Test diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java index 00943e55..e4447e50 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactoryTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; @@ -37,7 +38,7 @@ class CacheHandlerChainFactoryTest { @BeforeEach void setUp() { properties = mock(RedisProCacheProperties.class); - factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); } private CacheContext testContext() { return CacheContext.of(CacheInput.builder() @@ -59,7 +60,7 @@ class CreateChainTests { @Test @DisplayName("creates empty chain when no handlers provided") void createChain_noHandlers_createsEmptyChain() { - factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(Collections.emptyList(), properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -74,7 +75,7 @@ void createChain_multipleHandlers_addsAllToChain() { new AnotherTestHandler(), new YetAnotherTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -89,7 +90,7 @@ void createChain_withPriorities_sortsCorrectly() { new BloomFilterTestHandler(), new SyncLockTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheResult result = factory.createChain().execute(testContext()); @@ -103,7 +104,7 @@ void createChain_noAnnotation_getsMaxPriority() { new TestCacheHandler(), new PriorityTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheResult result = factory.createChain().execute(testContext()); @@ -117,7 +118,7 @@ void createChain_multipleHandlers_linksCorrectly() { new TestCacheHandler(), new AnotherTestHandler() ); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -137,7 +138,7 @@ void createChain_disabledHandlersGlobally_filtersOut() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(List.of("test-cache")); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -153,7 +154,7 @@ void createChain_kebabCaseMapping_worksCorrectly() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(List.of("test-cache")); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -169,7 +170,7 @@ void createChain_emptyDisabledList_keepsAllHandlers() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(Collections.emptyList()); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -185,7 +186,7 @@ void createChain_allDisabled_resultsInEmptyChain() { new AnotherTestHandler() ); when(properties.getDisabledHandlers()).thenReturn(List.of("test-cache", "another-test")); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -208,7 +209,7 @@ void protectionDisabled_preservesTtlAndActualCache() { List handlers = List.of( new BloomFilterHandler(), new SyncLockHandler(), new EarlyExpirationHandler(), new TtlHandler(), new NullValueHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -224,7 +225,7 @@ void protectionEnabled_keepsAll() { List handlers = List.of( new BloomFilterHandler(), new TtlHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -336,7 +337,7 @@ private CacheHandlerChain chainFor( List handlers = List.of(new BloomFilterHandler(), new SyncLockHandler(), new EarlyExpirationHandler(), new TtlHandler(), new NullValueHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); return factory.createChain(); } @@ -350,7 +351,7 @@ void protectionDisabled_disableNameFromAnnotation_notClassName() { List handlers = List.of( new OddlyNamedBloomHandler(), new TtlHandler(), new ActualCacheHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -365,7 +366,7 @@ void globalDisabled_disableNameFromAnnotation_notClassName() { List handlers = List.of( new WeirdlyNamedLockHandler(), new TtlHandler()); - factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()); + factory = new CacheHandlerChainFactory(handlers, properties, ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()); CacheHandlerChain chain = factory.createChain(); @@ -390,7 +391,10 @@ void createChain_withRegistry_attachesAndIncrementsFiredCounter() { // P1-API-001-C:observer 为有序 Bean,工厂经主构造注入 List 后单一注册。 // 主构造注入的 registry 驱动 fired counter observer bean。 factory = new CacheHandlerChainFactory( - List.of(probe), properties, ResolvedMetrics.resolve(provider, null), new ChainEngine(), + List.of(probe), properties, + ResolvedMetrics.resolve(provider, new MockEnvironment() + .withProperty("resi-cache.metrics.enabled", "true")), + new ChainEngine(), List.of(new io.github.davidhlp.spring.cache.redis.cache.FiredCounterChainObserver(registry))); CacheHandlerChain chain = factory.createChain(); diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index bf89176f..a8a5a221 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -108,13 +108,12 @@ void multipleStartEnd_generateDifferentIds() { class TimerTests { @Test - @DisplayName("registry 缺失时节点计时 no-op") - void nullRegistry_noOp() { - ChainObserver observer = new ChainTimerChainObserver(null); + @DisplayName("no-op seam 时节点计时不落任何出口,不抛异常") + void noopSeam_noOp() { + ChainObserver observer = new ChainTimerChainObserver(ResolvedMetrics.NOOP_REGISTRY); Object scopeToken = observer.onNodeStart(handler, ctx); - assertThat(scopeToken).isNull(); observer.onNodeEnd(handler, ctx, scopeToken, HandlerResult.continueChain()); } @@ -229,9 +228,9 @@ public HandlerResult handle(CacheContext context) { class FiredCounterTests { @Test - @DisplayName("registry 缺失 → afterNode 自增调用为 no-op,不抛异常") + @DisplayName("no-op seam → afterNode 自增不落任何出口,不抛异常") void nullRegistry_noOp() { - ChainObserver observer = new FiredCounterChainObserver(null); + ChainObserver observer = new FiredCounterChainObserver(ResolvedMetrics.NOOP_REGISTRY); observer.afterNode(handler, ctx, HandlerResult.continueChain()); // 无异常即可 } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java index 8356fbdc..eeb9de95 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java @@ -1,6 +1,5 @@ package io.github.davidhlp.spring.cache.redis.cache; -import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; @@ -26,7 +25,7 @@ void reportsConnectivityAndProtectionDegradation() { when(syncSupport.isDegraded()).thenReturn(true); RedisCacheHealthIndicator indicator = new RedisCacheHealthIndicator( - template, provider(syncSupport), provider(null)); + template, provider(syncSupport)); Health health = indicator.health(); @@ -44,7 +43,7 @@ void reportsUnexpectedPingResponseAsDown() { when(template.execute(any(RedisCallback.class))).thenReturn("NOPE"); Health health = new RedisCacheHealthIndicator( - template, provider(null), provider(null)).health(); + template, provider(null)).health(); assertThat(health.getStatus()).isEqualTo(Status.DOWN); assertThat(health.getDetails().get("status")).isEqualTo("unexpected response: NOPE"); @@ -57,7 +56,7 @@ void reportsPingExceptionAsDown() { when(template.execute(any(RedisCallback.class))).thenThrow(new IllegalStateException("Redis unavailable")); Health health = new RedisCacheHealthIndicator( - template, provider(null), provider(null)).health(); + template, provider(null)).health(); assertThat(health.getStatus()).isEqualTo(Status.DOWN); assertThat(health.getDetails()).containsEntry("error", "Redis unavailable"); diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index 4831597d..c703bbda 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -89,7 +89,7 @@ void metricsEnabled_withoutMeterRegistry_keepsNoOpChoice() throws Exception { assertThat(context).doesNotHaveBean(MeterRegistry.class); assertThat(context).hasSingleBean(ResolvedMetrics.class); assertThat(context.getBean(ResolvedMetrics.class).meterRegistry()) - .isNull(); + .isSameAs(ResolvedMetrics.NOOP_REGISTRY); }); } } From 6941bf2f1da873beb411b4d4c5a90f6977c30ebd Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:04:29 +0800 Subject: [PATCH 23/56] docs: record the single metrics seam and ungated health indicator REFERENCE.md names resi-cache.metrics.enabled (default false, read once by ResolvedMetrics, declared in the additional configuration metadata), OPERATIONS.md states that the disabled case is a no-op seam and that the Redis health indicator is not metrics-gated, and COMPATIBILITY.md drops the stale statement that the indicator requires the metrics property. --- COMPATIBILITY.md | 7 ++++--- docs/OPERATIONS.md | 14 ++++++++------ docs/REFERENCE.md | 11 +++++++++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 80596753..982b81b1 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -56,9 +56,10 @@ baseline. sync operation fails fast unless `resi-cache.sync-lock.local-only=true` is explicitly configured. | | **Micrometer / Actuator** | Optional | Cache metrics require - `resi-cache.metrics.enabled=true` (default OFF) and a `MeterRegistry`. - `RedisCacheHealthIndicator` additionally requires Actuator, the - `HealthIndicator` class, and the same metrics property set to `true`. | + `resi-cache.metrics.enabled=true` (default OFF) and a `MeterRegistry`; + otherwise the resolved metrics seam is a no-op adapter. + `RedisCacheHealthIndicator` requires Actuator and the `HealthIndicator` + class; it is not gated on the metrics property. | | **Caffeine** | Bundled | Used internally for the local hash cache and bloom-filter bitset; not exposed as a multi-level cache. | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 428920e5..680ad20d 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -39,12 +39,14 @@ and must not be treated as multi-instance protection. ## Observability and diagnosis -Metrics and the Redis health indicator are opt-in. Metrics require both -`resi-cache.metrics.enabled=true` and the application's `MeterRegistry`; the -health indicator additionally requires the optional Actuator dependency and -the same metrics property to be enabled. Writer statistics and failure -reporting are bounded by the contracts in `STABILITY.md` and `COMPATIBILITY.md`; -pre-1.0 metric names and log wording are not a general compatibility promise. +Cache metrics are opt-in: they require both `resi-cache.metrics.enabled=true` +and the application's `MeterRegistry`, a decision resolved once during +assembly. When either is missing the metrics seam is a no-op adapter and +nothing is published. The Redis health indicator is not gated by that switch; +it needs the optional Actuator dependency and reports Redis connectivity plus +protection degradation. Writer statistics and failure reporting are bounded by +the contracts in `STABILITY.md` and `COMPATIBILITY.md`; pre-1.0 metric names +and log wording are not a general compatibility promise. WARN/ERROR diagnostics omit raw cache keys. The source uses cache-name or a short diagnostic fingerprint where available and keeps fuller detail at lower diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 76c506f1..4fe847e3 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -36,8 +36,15 @@ The main configuration groups are: - `redis.*` topology/TLS/deployment fields; - `serializer.*` and operator migration settings; - per-cache overrides under `caches.*`; -- optional `disabled-handlers`, metrics, and feature controls defined by the - current source. +- optional `disabled-handlers` and feature controls defined by the current + source; +- `resi-cache.metrics.enabled` (`java.lang.Boolean`, default `false`) — the + metrics opt-in. It has no `RedisProCacheProperties` field: package-private + `ResolvedMetrics` (`cache/`) reads it in exactly one place and hands every + caller a non-null metrics seam — the application `MeterRegistry` when the + property is `true` and such a bean exists, otherwise a shared no-op adapter. + Its metadata comes from + `additional-spring-configuration-metadata.json`. Configuration is validated at binding time. Do not infer a default from an old README snippet when the properties class or generated metadata differs. From e1ff73c73e554acbcd120dbcb763e2f4ea53a6a5 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:24:40 +0800 Subject: [PATCH 24/56] refactor(cache): consolidate sync role lifecycle into the state owner Fold the re-entrant 7-argument SyncSupport.executeRoleWork static into an executeRoleWork method on the SyncStateAccess owner, and move the SyncState registry impl next to the roles it mutates so state and lifecycle read in one file. Extract the once-only publication ordering (enter -> complete -> exit -> cleanup) into Leader.finishPublication as a stated protocol. Behavior, log strings/levels, lock pairing and the public nested role types are unchanged; add a SyncRoleTest check that the lifecycle registers each step exactly once. --- .../spring/cache/redis/cache/SyncRole.java | 195 ++++++++++++++++-- .../spring/cache/redis/cache/SyncSupport.java | 142 +------------ .../cache/redis/cache/SyncRoleTest.java | 41 ++++ 3 files changed, 219 insertions(+), 159 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java index 6e816b78..ad8fdbf1 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncRole.java @@ -1,17 +1,18 @@ package io.github.davidhlp.spring.cache.redis.cache; - - - - import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; import java.util.Deque; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; @@ -27,8 +28,10 @@ *
  14. {@link Exclusive} — 写路径只做互斥锁和本地串行,不发布或清理 single-flight 注册。
  15. * * - *

    角色只接收 {@link SyncStateAccess} 的窄生命周期契约,不直接持有三个 registry。这样一个 - * per-key sync state 只有一个 owner,发布、完成和清理由同一处负责。 + *

    本文件同时承载角色与它们变更的 per-key 状态: {@link SyncStateAccess} 生命周期契约、 + * 唯一实现 {@link SyncState}(key registry、重入标记、local-only 队列)、 + * {@link SyncRegistration} 发布凭据与 {@link SyncRoleLockExecutor} 分布式锁执行。角色只通过 + * {@link SyncStateAccess} 变更状态,不直接持有三个 registry —— 状态与其生命周期同处一个 owner。 * *

    包私有:仅 SyncSupport 和同包测试使用,不对外暴露。 */ @@ -86,8 +89,7 @@ public T run() { RuntimeException failure = null; boolean success = false; try { - value = SyncSupport.executeRoleWork( - log, key, timeout, loader, distributedManagers, properties, state); + value = state.executeRoleWork(log, key, timeout, loader, distributedManagers, properties); success = true; } catch (final RuntimeException e) { failure = e; @@ -105,20 +107,34 @@ public T run() { throw error; } } finally { - Throwable completionFailure = success - ? null - : failure != null - ? failure - : new IllegalStateException("Single-flight leader aborted before completing"); - state.complete(registration, value, completionFailure); - state.exit(key); - state.cleanup(registration); + finishPublication(value, failure, success); } if (success) { return value; } throw failure; } + + /** + * Once-only publication lifecycle for a single key: {@code enter} (performed by + * {@link #run} before the work) → {@code complete} → {@code exit} → {@code cleanup}. + * + *

    Called exactly once from {@link #run}'s {@code finally}, including the {@link Error} + * rethrow path. Ordering is load-bearing: completion of the shared future must be visible + * before this thread's reentrancy mark is cleared, and the leader's own registration is + * removed last so followers that joined can never observe a cleaned-up-but-incomplete + * publication. + */ + private void finishPublication(T value, RuntimeException failure, boolean success) { + Throwable completionFailure = success + ? null + : failure != null + ? failure + : new IllegalStateException("Single-flight leader aborted before completing"); + state.complete(registration, value, completionFailure); + state.exit(key); + state.cleanup(registration); + } } /** @@ -208,8 +224,7 @@ final class Exclusive implements SyncRole { public T run() { state.enter(key); try { - return SyncSupport.executeRoleWork( - log, key, timeout, work, distributedManagers, properties, state); + return state.executeRoleWork(log, key, timeout, work, distributedManagers, properties); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException( @@ -222,7 +237,11 @@ public T run() { } } -/** Narrow lifecycle contract owned by SyncSupport's per-key state holder. */ +/** + * Narrow lifecycle and execution contract owned by the per-key state holder ({@link SyncState}). + * Roles mutate the single-flight state only through this seam, so publication, completion, cleanup, + * local-only serialisation and the distributed-lock dispatch decision share one owner. + */ interface SyncStateAccess { boolean isReentrant(String key); @@ -238,12 +257,150 @@ interface SyncStateAccess { void cleanup(SyncRegistration registration); T executeLocalOnly(String key, SyncLockTimeout.Resolved timeout, Supplier work); + + /** + * 选择分布式锁、本地串行或 fail-fast 路径。local-only fallback 由 state owner 负责, + * 锁执行器只处理真正的分布式锁。 + * + *

    这是角色实际执行工作(或降级/fail-fast)的唯一入口,由 {@link SyncRole.Leader} 与 + * {@link SyncRole.Exclusive} 通过自己持有的 {@code state} 调用,不再回指构造它们的 SyncSupport。 + */ + T executeRoleWork(Logger log, + String key, + SyncLockTimeout.Resolved timeout, + Supplier work, + List distributedManagers, + RedisProCacheProperties properties) throws InterruptedException; } /** A single-flight publication returned by the state owner. */ record SyncRegistration(String key, CompletableFuture future, boolean leader) { } +/** + * 一个 owner 统一管理 key registry 的 publication、completion、cleanup 和 local-only queue, + * 并负责在分布式锁缺失时选择 local-only 降级或 fail-fast。 + */ +final class SyncState implements SyncStateAccess { + + private final ConcurrentMap> inFlight = new ConcurrentHashMap<>(); + private final ConcurrentMap> localOnlyTails = new ConcurrentHashMap<>(); + private final ThreadLocal> reentrantKeys = ThreadLocal.withInitial(HashSet::new); + + @Override + public boolean isReentrant(String key) { + return reentrantKeys.get().contains(key); + } + + @Override + public void enter(String key) { + reentrantKeys.get().add(key); + } + + @Override + public void exit(String key) { + reentrantKeys.get().remove(key); + } + + @Override + public SyncRegistration publish(String key) { + CompletableFuture mine = new CompletableFuture<>(); + CompletableFuture existing = inFlight.putIfAbsent(key, mine); + return existing == null + ? new SyncRegistration(key, mine, true) + : new SyncRegistration(key, existing, false); + } + + @Override + public void complete(SyncRegistration registration, Object value, Throwable failure) { + if (failure == null) { + registration.future().complete(value); + } else { + registration.future().completeExceptionally(failure); + } + } + + @Override + public void cleanup(SyncRegistration registration) { + if (registration.leader()) { + // 只移除自己发布的 future,避免误删后一个 leader。 + inFlight.remove(registration.key(), registration.future()); + } + } + + @Override + public T executeLocalOnly(String key, + SyncLockTimeout.Resolved timeout, + Supplier work) { + AtomicReference> predecessorRef = new AtomicReference<>(); + CompletableFuture current = new CompletableFuture<>(); + CompletableFuture tail = localOnlyTails.compute(key, (ignored, predecessor) -> { + predecessorRef.set(predecessor); + return predecessor == null ? current : CompletableFuture.allOf(predecessor, current); + }); + tail.whenComplete((ignored, failure) -> localOnlyTails.remove(key, tail)); + CompletableFuture predecessor = predecessorRef.get(); + try { + if (predecessor != null) { + awaitPredecessor(key, predecessor, timeout); + } + return work.get(); + } finally { + current.complete(null); + } + } + + @Override + public T executeRoleWork(Logger log, + String key, + SyncLockTimeout.Resolved timeout, + Supplier work, + List distributedManagers, + RedisProCacheProperties properties) throws InterruptedException { + if (distributedManagers.isEmpty()) { + if (properties.getSyncLock().isLocalOnly()) { + FailureReport.warn(log, + "protection.degraded=local-only: sync=true 但无分布式锁后端, " + + "已按 local-only=true 降级为单 JVM 同步", + null, key); + return executeLocalOnly(key, timeout, work); + } + // Key-privacy contract: exception message omits raw key. + throw new IllegalStateException( + "sync=true 已声明但无分布式锁后端 (无 RedissonClient / LockManager bean)。" + + "拒绝静默退化为单 JVM synchronized (多实例下无法防击穿)。" + + "请引入 Redisson, 或显式设 resi-cache.sync-lock.local-only=true 接受单实例降级。" + + " [keyFingerprint=" + FailureReport.fingerprint(key) + "]"); + } + return SyncRoleLockExecutor.run(log, key, timeout, work, distributedManagers); + } + + private static void awaitPredecessor(String key, + CompletableFuture predecessor, + SyncLockTimeout.Resolved timeout) { + long timeoutSeconds = timeout.seconds(); + try { + predecessor.get(Math.max(timeoutSeconds, 0L), TimeUnit.SECONDS); + } catch (final TimeoutException e) { + throw new IllegalStateException( + "Timed out after " + timeoutSeconds + + "s waiting for the local-only predecessor (keyFingerprint=" + + FailureReport.fingerprint(key) + ")", e); + } catch (final ExecutionException e) { + // 前驱 future 只会 complete(null);兜底避免把 checked 异常漏给调用方。 + throw new IllegalStateException( + "Local-only predecessor failed (keyFingerprint=" + + FailureReport.fingerprint(key) + ")", + e.getCause() != null ? e.getCause() : e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Thread interrupted while waiting for the local-only predecessor (keyFingerprint=" + + FailureReport.fingerprint(key) + ")", e); + } + } +} + /** Shared lock execution for the explicit Leader and Exclusive role cases. */ final class SyncRoleLockExecutor { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java index 55d8f4fd..d0fb3a90 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java @@ -6,23 +6,13 @@ import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; -import org.slf4j.Logger; import org.springframework.stereotype.Component; /** - * 通过 in-flight {@link CompletableFuture} 实现 single-flight 同步加载: + * 通过 in-flight {@code CompletableFuture} 实现 single-flight 同步加载: * 同一 key 的并发请求中,只有 leader 线程获取分布式锁并执行 loader, * follower 线程共享 leader 的结果(不重复获取分布式锁、不重复回源)。 * @@ -34,7 +24,7 @@ * (1 个回源)反而更硬。 *
  16. 可重入(future 不可重入陷阱):chain 内 {@code SyncLockHandler} 会嵌套重入 * {@code executeSync}(同 key —— {@code RedisProCache.executeSyncLoad} 的 loader 内 - * {@code super.get} → chain GET → SyncLockHandler 再次进入)。{@link CompletableFuture} + * {@code super.get} → chain GET → SyncLockHandler 再次进入)。{@code CompletableFuture} * 不可重入(leader 重入会 join 自己 → 死锁),故用 {@link ThreadLocal} 标记当前线程 * 已持有的 key,重入时走 fast-path 直接跑 loader —— 语义等价,且省去二次分布式锁往返。
  17. *
  18. 失败传播:leader loader 抛异常 → future {@code completeExceptionally}, @@ -139,35 +129,6 @@ T executeSync(final String key, return electRole(key, loader, timeout).run(); } - /** - * 选择分布式锁、本地串行或 fail-fast 路径。local-only fallback 由 state owner - * 负责,锁执行器只处理真正的分布式锁。 - */ - static T executeRoleWork(Logger log, - String key, - SyncLockTimeout.Resolved timeout, - Supplier work, - List distributedManagers, - RedisProCacheProperties properties, - SyncStateAccess state) throws InterruptedException { - if (distributedManagers.isEmpty()) { - if (properties.getSyncLock().isLocalOnly()) { - FailureReport.warn(log, - "protection.degraded=local-only: sync=true 但无分布式锁后端, " - + "已按 local-only=true 降级为单 JVM 同步", - null, key); - return state.executeLocalOnly(key, timeout, work); - } - // Key-privacy contract: exception message omits raw key. - throw new IllegalStateException( - "sync=true 已声明但无分布式锁后端 (无 RedissonClient / LockManager bean)。" - + "拒绝静默退化为单 JVM synchronized (多实例下无法防击穿)。" - + "请引入 Redisson, 或显式设 resi-cache.sync-lock.local-only=true 接受单实例降级。" - + " [keyFingerprint=" + FailureReport.fingerprint(key) + "]"); - } - return SyncRoleLockExecutor.run(log, key, timeout, work, distributedManagers); - } - /** * 执行独占工作 —— 写路径用:只用分布式锁做互斥,不做 single-flight 结果共享。 * @@ -209,103 +170,4 @@ private SyncRole electRole(String key, } return new SyncRole.Follower<>(key, registration.future(), timeout); } - - /** - * 一个 owner 统一管理 key registry 的 publication、completion、cleanup 和 local-only queue。 - * 角色只能通过 {@link SyncStateAccess} 调用这些动作,不能直接操作 registry。 - */ - private static final class SyncState implements SyncStateAccess { - - private final ConcurrentMap> inFlight = new ConcurrentHashMap<>(); - private final ConcurrentMap> localOnlyTails = new ConcurrentHashMap<>(); - private final ThreadLocal> reentrantKeys = ThreadLocal.withInitial(HashSet::new); - - @Override - public boolean isReentrant(String key) { - return reentrantKeys.get().contains(key); - } - - @Override - public void enter(String key) { - reentrantKeys.get().add(key); - } - - @Override - public void exit(String key) { - reentrantKeys.get().remove(key); - } - - @Override - public SyncRegistration publish(String key) { - CompletableFuture mine = new CompletableFuture<>(); - CompletableFuture existing = inFlight.putIfAbsent(key, mine); - return existing == null - ? new SyncRegistration(key, mine, true) - : new SyncRegistration(key, existing, false); - } - - @Override - public void complete(SyncRegistration registration, Object value, Throwable failure) { - if (failure == null) { - registration.future().complete(value); - } else { - registration.future().completeExceptionally(failure); - } - } - - @Override - public void cleanup(SyncRegistration registration) { - if (registration.leader()) { - // 只移除自己发布的 future,避免误删后一个 leader。 - inFlight.remove(registration.key(), registration.future()); - } - } - - @Override - public T executeLocalOnly(String key, - SyncLockTimeout.Resolved timeout, - Supplier work) { - AtomicReference> predecessorRef = new AtomicReference<>(); - CompletableFuture current = new CompletableFuture<>(); - CompletableFuture tail = localOnlyTails.compute(key, (ignored, predecessor) -> { - predecessorRef.set(predecessor); - return predecessor == null ? current : CompletableFuture.allOf(predecessor, current); - }); - tail.whenComplete((ignored, failure) -> localOnlyTails.remove(key, tail)); - CompletableFuture predecessor = predecessorRef.get(); - try { - if (predecessor != null) { - awaitPredecessor(key, predecessor, timeout); - } - return work.get(); - } finally { - current.complete(null); - } - } - - private static void awaitPredecessor(String key, - CompletableFuture predecessor, - SyncLockTimeout.Resolved timeout) { - long timeoutSeconds = timeout.seconds(); - try { - predecessor.get(Math.max(timeoutSeconds, 0L), TimeUnit.SECONDS); - } catch (final TimeoutException e) { - throw new IllegalStateException( - "Timed out after " + timeoutSeconds - + "s waiting for the local-only predecessor (keyFingerprint=" - + FailureReport.fingerprint(key) + ")", e); - } catch (final ExecutionException e) { - // 前驱 future 只会 complete(null);兜底避免把 checked 异常漏给调用方。 - throw new IllegalStateException( - "Local-only predecessor failed (keyFingerprint=" - + FailureReport.fingerprint(key) + ")", - e.getCause() != null ? e.getCause() : e); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException( - "Thread interrupted while waiting for the local-only predecessor (keyFingerprint=" - + FailureReport.fingerprint(key) + ")", e); - } - } - } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncRoleTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncRoleTest.java index a1041e30..d664e96d 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncRoleTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncRoleTest.java @@ -1,6 +1,7 @@ package io.github.davidhlp.spring.cache.redis.cache; import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; +import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -12,6 +13,7 @@ import java.util.function.Supplier; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; import static org.assertj.core.api.Assertions.assertThat; @@ -81,6 +83,30 @@ void exclusiveRole_doesNotClaimSingleFlightRegistration() { assertThat(state.events).doesNotContain("publish", "complete", "cleanup"); } + @Test + @DisplayName("leader lifecycle fires enter/complete/exit/cleanup exactly once per publication") + void leaderLifecycle_runsEachPublicationStepExactlyOnce() { + RecordingState state = new RecordingState(); + SyncRegistration registration = state.publish("once-key"); + + String value = new SyncRole.Leader<>( + "once-key", + SyncLockTimeout.Resolved.fromSeconds(5), + () -> "ONCE", + registration, + List.of(), + localOnlyProperties(), + state).run(); + + assertThat(value).isEqualTo("ONCE"); + // Double-enter would corrupt the reentrancy mark; double-complete would double-publish + // the shared future. Both must be exactly once for a single leader run. + assertThat(state.enterCount).isEqualTo(1); + assertThat(state.completeCount).isEqualTo(1); + assertThat(state.exitCount).isEqualTo(1); + assertThat(state.cleanupCount).isEqualTo(1); + } + private static RedisProCacheProperties localOnlyProperties() { RedisProCacheProperties properties = new RedisProCacheProperties(); properties.getSyncLock().setLocalOnly(true); @@ -103,6 +129,10 @@ private static final class RecordingState implements SyncStateAccess { private final CountDownLatch completionObserved = new CountDownLatch(1); private final CompletableFuture future = new CompletableFuture<>(); private final SyncRegistration registration = new SyncRegistration("lifecycle-key", future, true); + private int enterCount; + private int completeCount; + private int exitCount; + private int cleanupCount; @Override public boolean isReentrant(String key) { @@ -111,11 +141,13 @@ public boolean isReentrant(String key) { @Override public void enter(String key) { + enterCount++; events.add("enter"); } @Override public void exit(String key) { + exitCount++; events.add("exit"); } @@ -127,6 +159,7 @@ public SyncRegistration publish(String key) { @Override public void complete(SyncRegistration published, Object value, Throwable failure) { + completeCount++; events.add("complete"); completionObserved.countDown(); if (failure == null) { @@ -138,6 +171,7 @@ public void complete(SyncRegistration published, Object value, Throwable failure @Override public void cleanup(SyncRegistration published) { + cleanupCount++; events.add("cleanup"); } @@ -146,5 +180,12 @@ public T executeLocalOnly(String key, SyncLockTimeout.Resolved timeout, Supplier work) { return work.get(); } + + @Override + public T executeRoleWork(Logger log, String key, SyncLockTimeout.Resolved timeout, + Supplier work, List distributedManagers, + RedisProCacheProperties properties) { + return work.get(); + } } } From 4d27e5d6fcd965080868df9f35d47bf5b953c31a Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:26:14 +0800 Subject: [PATCH 25/56] fix(bench): target TtlPolicy after TTL owner move TtlJitterBenchmark called TtlHandler.calculateFinalTtl, which c8 moved to package-private TtlPolicy. Same-named package in the bench module reaches the static directly; no public API re-added to TtlHandler. --- .../spring/cache/redis/cache/TtlJitterBenchmark.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/resicache-bench/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlJitterBenchmark.java b/resicache-bench/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlJitterBenchmark.java index da354772..6c97b03b 100644 --- a/resicache-bench/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlJitterBenchmark.java +++ b/resicache-bench/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlJitterBenchmark.java @@ -10,7 +10,7 @@ * *

    When many cache entries are written with the same TTL they expire in a * burst, causing a mass DB stampede (cache avalanche). ResiCache's - * {@link TtlHandler} adds a configurable random jitter to each entry's + * {@link TtlPolicy} adds a configurable random jitter to each entry's * TTL so expirations are spread evenly over time. * *

    We measure: @@ -34,7 +34,6 @@ @Fork(1) public class TtlJitterBenchmark { - private TtlHandler ttlHandler; private long baseTtlSeconds; /** @@ -46,7 +45,6 @@ public class TtlJitterBenchmark { @Setup(Level.Trial) public void setup() { - ttlHandler = new TtlHandler(); baseTtlSeconds = 600L; } @@ -56,7 +54,7 @@ public void setup() { */ @Benchmark public long ttlJitter_compute() { - return ttlHandler.calculateFinalTtl(baseTtlSeconds, true, jitterRatio); + return TtlPolicy.calculateFinalTtl(baseTtlSeconds, true, jitterRatio); } /** @@ -65,18 +63,18 @@ public long ttlJitter_compute() { */ @Benchmark public long ttlBaseline() { - return ttlHandler.calculateFinalTtl(baseTtlSeconds, false, 0.0f); + return TtlPolicy.calculateFinalTtl(baseTtlSeconds, false, 0.0f); } /** * Multi-threaded uniformity: 8 threads compute jitter concurrently. - * Validates that {@link ThreadLocalRandom} usage inside the handler + * Validates that {@link ThreadLocalRandom} usage inside the policy * has no contention under parallel write pressure. */ @Benchmark @Threads(8) public void ttlJitter_concurrent_uniformity(Blackhole bh) { - long jittered = ttlHandler.calculateFinalTtl(baseTtlSeconds, true, jitterRatio); + long jittered = TtlPolicy.calculateFinalTtl(baseTtlSeconds, true, jitterRatio); bh.consume(jittered); } } From 1860f066bc4cf06fe28b81356e069a0286597aac Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:28:56 +0800 Subject: [PATCH 26/56] refactor(cache): own handler slot identity in one declaration Handler identity was derived twice (annotation lookup plus a class-name fallback in the factory) and handlerTag was a class simple name, so renaming a handler silently moved a live Micrometer tag dimension. HandlerOrder now also declares each slot's observation tag and internal HandlerIdentity resolves order, disable name and tag from that single declaration; the class-name path remains only for handlers that own no standard slot. --- docs/ARCHITECTURE.md | 3 ++ .../cache/redis/cache/CacheHandlerChain.java | 2 +- .../redis/cache/CacheHandlerChainFactory.java | 44 +++--------------- .../cache/redis/cache/HandlerIdentity.java | 46 +++++++++++++++++++ .../cache/redis/chain/HandlerOrder.java | 34 ++++++++++---- 5 files changed, 82 insertions(+), 47 deletions(-) create mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5a1e36d4..73e58a0c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -73,6 +73,9 @@ classes can move or disappear without becoming a compatibility promise. assembles observers. `ChainEngine` owns advancement, flow decisions, observer hook ordering, and post-processing isolation. A custom handler must be supplied by the host application's component scan or as an application bean. +`HandlerOrder` additionally carries each slot's protection disable name and its +`handler` metric/log tag, which internal `cache/HandlerIdentity.java` resolves as +one declaration so that renaming a handler class changes neither. ## Annotation and policy flow diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChain.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChain.java index ee685132..efada826 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChain.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChain.java @@ -70,7 +70,7 @@ class CacheHandlerChain { /** Shared runtime label for handler logs and bounded observer tags. */ static String handlerTag(CacheHandler handler) { - return handler.getClass().getSimpleName(); + return HandlerIdentity.of(handler).tag(); } /** diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java index 34131187..7f7fb0a1 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java @@ -8,7 +8,6 @@ import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; import io.github.davidhlp.spring.cache.redis.chain.HandlerOrder; -import io.github.davidhlp.spring.cache.redis.chain.HandlerPriority; import io.github.davidhlp.spring.cache.redis.chain.observer.ChainObserver; import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import io.micrometer.core.instrument.MeterRegistry; @@ -171,17 +170,17 @@ public CacheHandlerChain createChain() { Set disabled = new HashSet<>(properties.getDisabledHandlers()); resolveProtectionDisabled(properties, disabled); - // 按 @HandlerPriority 注解排序 + // 按 @HandlerPriority 注解排序(身份三项的解析见 HandlerIdentity) List sortedHandlers = handlers.stream() - .sorted(Comparator.comparingInt(this::getOrder)) + .sorted(Comparator.comparingInt(handler -> HandlerIdentity.of(handler).order())) .toList(); // 添加到链,过滤禁用的 Handler for (CacheHandler handler : sortedHandlers) { - String handlerName = getHandlerDisableName(handler); + HandlerIdentity identity = HandlerIdentity.of(handler); - if (disabled.contains(handlerName)) { - log.info("Handler disabled by configuration: {}", CacheHandlerChain.handlerTag(handler)); + if (disabled.contains(identity.disableName())) { + log.info("Handler disabled by configuration: {}", identity.tag()); continue; } @@ -190,8 +189,8 @@ public CacheHandlerChain createChain() { ach.attachMeterRegistry(registry); } log.debug("Added handler to chain: {} (order={})", - CacheHandlerChain.handlerTag(handler), - getOrder(handler)); + identity.tag(), + identity.order()); } log.info("Handler chain created with {} handlers: {}", @@ -268,33 +267,4 @@ private static void resolveProtectionDisabled(RedisProCacheProperties properties } } } - - /** - * 获取 Handler 的禁用配置名称. - * - *

    优先从 {@code @HandlerPriority} 注解关联的 {@link HandlerOrder} 反查 - * {@link HandlerOrder#getDisableName()}(单一事实源),使 handler 类重命名不影响 - * 配置禁用语义。未标注注解的 handler 回退到类名派生(kebab-case)以保持兼容。 - */ - private String getHandlerDisableName(CacheHandler handler) { - HandlerPriority annotation = handler.getClass().getAnnotation(HandlerPriority.class); - if (annotation != null) { - return annotation.value().getDisableName(); - } - String className = CacheHandlerChain.handlerTag(handler); - return className.replace("Handler", "") - .replaceAll("([a-z])([A-Z])", "$1-$2") // camelCase to kebab-case - .toLowerCase(); - } - - /** - * 获取 Handler 的执行顺序 - * - * @param handler Handler 实例 - * @return 顺序值,未标注则返回 Integer.MAX_VALUE - */ - private int getOrder(CacheHandler handler) { - HandlerPriority annotation = handler.getClass().getAnnotation(HandlerPriority.class); - return annotation != null ? annotation.value().getOrder() : Integer.MAX_VALUE; - } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java new file mode 100644 index 00000000..a2217672 --- /dev/null +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java @@ -0,0 +1,46 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; +import io.github.davidhlp.spring.cache.redis.chain.HandlerOrder; +import io.github.davidhlp.spring.cache.redis.chain.HandlerPriority; + +/** + * 单个 handler 在链上的身份 —— slot、配置禁用名、观测标签 —— 一次解析,链路与观测共用。 + * + *

    标注 {@link HandlerPriority} 的 handler 三项全部取自 {@link HandlerOrder}(单一事实源), + * 因此 handler 类重命名既不改变 {@code resi-cache.disabled-handlers} / protection 开关的匹配, + * 也不改变 Micrometer {@code handler} tag 与链日志的取值。 + * + *

    未标注注解的 handler(宿主自定义 handler)不占据标准 slot,身份仍由类名派生 —— 这是唯一 + * 保留的类名派生路径,顺序值退到 {@link Integer#MAX_VALUE}(排在所有标准 slot 之后)。 + * + *

    标准 slot 的身份取值与跨 slot 的顺序要求由 {@code HandlerIdentityContractTest} 钉住。 + */ +record HandlerIdentity( + HandlerOrder slot, + int order, + String disableName, + String tag) { + + static HandlerIdentity of(CacheHandler handler) { + return of(handler.getClass()); + } + + static HandlerIdentity of(Class handlerClass) { + HandlerPriority priority = handlerClass.getAnnotation(HandlerPriority.class); + if (priority != null) { + HandlerOrder slot = priority.value(); + return new HandlerIdentity( + slot, slot.getOrder(), slot.getDisableName(), slot.getHandlerTag()); + } + String className = handlerClass.getSimpleName(); + return new HandlerIdentity(null, Integer.MAX_VALUE, toDisableName(className), className); + } + + /** 非标准 slot 的兼容派生:{@code BloomFilterHandler} → {@code bloom-filter}。 */ + private static String toDisableName(String className) { + return className.replace("Handler", "") + .replaceAll("([a-z])([A-Z])", "$1-$2") // camelCase to kebab-case + .toLowerCase(); + } +} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/chain/HandlerOrder.java b/src/main/java/io/github/davidhlp/spring/cache/redis/chain/HandlerOrder.java index 497c6c70..0369c74a 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/chain/HandlerOrder.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/chain/HandlerOrder.java @@ -7,24 +7,29 @@ * * 定义标准顺序,确保责任链按正确顺序执行。 * 间隔 100,便于插入新的 Handler。 + * + *

    本枚举同时是每个 slot 的身份事实源:顺序值、配置禁用名(kebab-case)、观测标签。 */ public enum HandlerOrder { - BLOOM_FILTER(100, "bloom-filter", "布隆过滤器-防穿透"), - SYNC_LOCK(200, "sync-lock", "分布式锁-防击穿"), - EARLY_EXPIRATION(250, "early-expiration", "提前过期-热key保护"), - TTL(300, "ttl", "TTL计算"), - NULL_VALUE(400, "null-value", "空值处理"), - ACTUAL_CACHE(500, "actual-cache", "实际缓存操作"); + BLOOM_FILTER(100, "bloom-filter", "布隆过滤器-防穿透", "BloomFilterHandler"), + SYNC_LOCK(200, "sync-lock", "分布式锁-防击穿", "SyncLockHandler"), + EARLY_EXPIRATION(250, "early-expiration", "提前过期-热key保护", "EarlyExpirationHandler"), + TTL(300, "ttl", "TTL计算", "TtlHandler"), + NULL_VALUE(400, "null-value", "空值处理", "NullValueHandler"), + ACTUAL_CACHE(500, "actual-cache", "实际缓存操作", "ActualCacheHandler"); private final int order; /** 配置禁用名称(kebab-case),handler 禁用标识的单一事实源 */ private final String disableName; private final String description; + /** 观测标签,链日志与 Micrometer {@code handler} tag 取值的单一事实源 */ + private final String handlerTag; - HandlerOrder(int order, String disableName, String description) { + HandlerOrder(int order, String disableName, String description, String handlerTag) { this.order = order; this.disableName = disableName; this.description = description; + this.handlerTag = handlerTag; } public int getOrder() { @@ -34,8 +39,8 @@ public int getOrder() { /** * 配置禁用名称(kebab-case),作为 handler 禁用标识的单一事实源。 * - *

    {@link io.github.davidhlp.spring.cache.redis.cache.CacheHandlerChainFactory} 通过 - * {@code @HandlerPriority} 注解关联的 {@link HandlerOrder} 反查此名称,而非从类名派生—— + *

    链装配经内部 {@code cache/HandlerIdentity} 从 {@code @HandlerPriority} 注解关联的 + * {@link HandlerOrder} 反查此名称,而非从类名派生—— * 这样 handler 类重命名不会导致 {@code resi-cache.disabled-handlers} 配置或 * {@code protection.enabled=false} 短路静默失效。 */ @@ -46,4 +51,15 @@ public String getDisableName() { public String getDescription() { return description; } + + /** + * 观测标签(handler 简名),链日志与 Micrometer {@code handler} tag 的单一事实源。 + * + *

    取值与历史一致,但由本枚举声明而非 {@code Class#getSimpleName()} 派生:重命名 handler + * 类不再静默改变已上线的 metric tag 维度,改动此值才是改变(并由 + * {@code HandlerIdentityContractTest} 钉住)。 + */ + public String getHandlerTag() { + return handlerTag; + } } From d3fcf64ea2849087646df6049a16ee055e593ac2 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:29:10 +0800 Subject: [PATCH 27/56] test(cache): pin handler identity values and slot ordering requirements The metric tag values and kebab disable names had no coverage, and the chain's cross-slot requirements lived only in comments. This pins the six standard slots' order/disableName/tag and makes the TTL-before-ActualCache and ActualCache-last requirements fail a test if a slot moves. --- .../cache/HandlerIdentityContractTest.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java new file mode 100644 index 00000000..96bad0c1 --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java @@ -0,0 +1,79 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; +import io.github.davidhlp.spring.cache.redis.chain.HandlerOrder; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +/** + * 链的跨 handler 契约守卫 —— handler 身份取值 + slot 间的顺序要求。 + * + *

    身份(order / disableName / tag)自 {@link HandlerOrder} 一次声明,由 + * {@link HandlerIdentity} 解析;本测试钉住冻结值:metric {@code handler} tag 与 + * {@code resi-cache.disabled-handlers} / protection 开关的 kebab 名称。 + * + *

    三条过去只存在于注释里的 slot 要求,现在的守卫: + *

      + *
    1. {@code TtlHandler} 必须先于 {@code ActualCacheHandler}(产出 {@code TtlDecision} 供其读取) + * → {@link #ttlSlotPrecedesActualCacheSlot()}
    2. + *
    3. {@code ActualCacheHandler} 必须运行在最后(消费 {@code PrefetchDecision}) + * → {@link #actualCacheSlotIsLastSlot()};行为侧证据是 + * {@code EarlyExpirationHandlerIntegrationTest} 里经真实工厂装配链断言 sync 跳过收敛为 MISS + * —— 消费者若被排到生产者之前,该断言即红
    4. + *
    5. {@code EarlyExpirationHandler} 在其 slot 上不得 {@code skipAll()}(引擎把 SKIP_ALL 映射成 + * success,与 sync 刷新的 miss 语义相悖)→ 同一集成断言 + + * {@code EarlyExpirationHandlerIntegrationTest.doHandle_nullMode_defaultsToSync} 直接断言 + * sync 路径的 {@code FlowControl.CONTINUE}
    6. + *
    + */ +@DisplayName("handler identity values and cross-slot ordering contract") +class HandlerIdentityContractTest { + + /** 六个标准 slot,按链上装配顺序排列。 */ + private static final List> STANDARD_HANDLERS_IN_CHAIN_ORDER = List.of( + BloomFilterHandler.class, + SyncLockHandler.class, + EarlyExpirationHandler.class, + TtlHandler.class, + NullValueHandler.class, + ActualCacheHandler.class); + + @Test + @DisplayName("标准 handler 的 order / disableName / metric tag 与冻结值一致") + void standardSlots_identityMatchesFrozenValues() { + assertThat(STANDARD_HANDLERS_IN_CHAIN_ORDER.stream().map(HandlerIdentity::of).toList()) + .extracting(HandlerIdentity::order, HandlerIdentity::disableName, HandlerIdentity::tag) + .containsExactly( + tuple(100, "bloom-filter", "BloomFilterHandler"), + tuple(200, "sync-lock", "SyncLockHandler"), + tuple(250, "early-expiration", "EarlyExpirationHandler"), + tuple(300, "ttl", "TtlHandler"), + tuple(400, "null-value", "NullValueHandler"), + tuple(500, "actual-cache", "ActualCacheHandler")); + } + + @Test + @DisplayName("TTL slot 先于 ActualCache slot(后者读取 TtlDecision)") + void ttlSlotPrecedesActualCacheSlot() { + assertThat(HandlerIdentity.of(TtlHandler.class).order()) + .as("工厂按 HandlerIdentity#order 排序,此序即装配序") + .isLessThan(HandlerIdentity.of(ActualCacheHandler.class).order()); + } + + @Test + @DisplayName("ActualCache 是最后一个 slot(它消费链上所有决策)") + void actualCacheSlotIsLastSlot() { + int actualCache = HandlerIdentity.of(ActualCacheHandler.class).order(); + + assertThat(actualCache).isEqualTo(HandlerOrder.ACTUAL_CACHE.getOrder()); + assertThat(Arrays.stream(HandlerOrder.values()) + .mapToInt(HandlerOrder::getOrder).max().orElseThrow()) + .as("新 slot 不得排在 ActualCache 之后") + .isEqualTo(actualCache); + } +} From 082ea13a5dfc9cc79746c6319696355f359c0277 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:29:11 +0800 Subject: [PATCH 28/56] test(cache): build the early-expiration miss chain through the factory The hand-assembled chain asserted the producer/consumer contract while also hard-coding the order it was meant to verify. Assembling the same handlers through CacheHandlerChainFactory makes the declared slot order and the no-skipAll rule the things under test. --- ...EarlyExpirationHandlerIntegrationTest.java | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java index ad8db8f9..2d93507e 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java @@ -5,15 +5,18 @@ +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; import io.github.davidhlp.spring.cache.redis.chain.CacheResult; import io.github.davidhlp.spring.cache.redis.chain.FlowControl; import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; import io.github.davidhlp.spring.cache.redis.chain.model.EarlyExpirationDecision; +import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; import io.github.davidhlp.spring.cache.redis.protection.refresh.EarlyExpirationMode; import java.time.Clock; import java.time.Duration; +import java.util.List; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -280,23 +283,29 @@ void doHandle_nullMode_defaultsToSync() { * Chain-level contract: a synchronous early-expiration skip must surface as a MISS * through the real engine — the documented producer/consumer pair * (EarlyExpirationHandler writes {@code PrefetchDecision}, ActualCacheHandler reads it). + * + *

    Assembly goes through the production {@link CacheHandlerChainFactory}, so the slot order + * declared by {@code HandlerOrder}/{@code @HandlerPriority} is what this asserts: a slot moved + * ahead of the consumer, or a {@code skipAll()} at the early-expiration slot, ends the chain + * with a SUCCESS and turns these assertions red. */ @Nested @DisplayName("chain-level contract - sync refresh surfaces a miss") class ChainLevelMissContractTests { private CacheHandlerChain productionChain() { - ActualCacheHandler actual = new ActualCacheHandler( - redisTemplate, - valueOperations, - valueCodec, - earlyExpirationExecutor, - new CacheErrorHandler()); - return new CacheHandlerChain(new ChainEngine()) - .addHandler(handler) // EarlyExpirationHandler (250) - .addHandler(new TtlHandler()) // 300 — write-path only - .addHandler(new NullValueHandler()) // 400 — write-path only - .addHandler(actual); // 500 — the documented consumer + List unordered = List.of( + new ActualCacheHandler( + redisTemplate, + valueOperations, + valueCodec, + earlyExpirationExecutor, + new CacheErrorHandler()), + new NullValueHandler(), // 400 — write-path only + new TtlHandler(), // 300 — write-path only + handler); // 250 — EarlyExpirationHandler, the producer + return new CacheHandlerChainFactory(unordered, new RedisProCacheProperties(), + ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()).createChain(); } @Test From 9508ddbb8b60b905ac6bad4f85c178fd7561bf06 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:33:56 +0800 Subject: [PATCH 29/56] fix(test): drive the order test through the required Environment seam --- .../davidhlp/spring/cache/redis/cache/ChainObserverTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index ea091cd8..d6e637b1 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.slf4j.MDC; import org.springframework.core.annotation.Order; +import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -279,7 +280,7 @@ void createChain_dispatchesByOrderNotInjectionOrder() { CacheHandlerChain chain = new CacheHandlerChainFactory( List.of(new SingleNodeHandler()), properties, - ResolvedMetrics.resolve(null, null), new ChainEngine(), injected) + ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), injected) .createChain(); chain.execute(ctx); From 30685536545d3d21c86a5cb65ee19ff207e81032 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 10:57:05 +0800 Subject: [PATCH 30/56] fix(test): drive the chain-contract test through the required Environment seam --- .../redis/cache/EarlyExpirationHandlerIntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java index 2d93507e..7e39987f 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/EarlyExpirationHandlerIntegrationTest.java @@ -29,6 +29,7 @@ import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -305,7 +306,7 @@ private CacheHandlerChain productionChain() { new TtlHandler(), // 300 — write-path only handler); // 250 — EarlyExpirationHandler, the producer return new CacheHandlerChainFactory(unordered, new RedisProCacheProperties(), - ResolvedMetrics.resolve(null, null), new ChainEngine(), List.of()).createChain(); + ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), List.of()).createChain(); } @Test From 38f1ba1d19e0f8df722189e625ef9aa62b0f3083 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:22:23 +0800 Subject: [PATCH 31/56] fix(cache): restore null-decision protocol guard --- .../spring/cache/redis/cache/ChainEngine.java | 6 +++ .../cache/HandlerResultProtocolTest.java | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java index 1aa19953..8dcc3123 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java @@ -158,6 +158,12 @@ private CacheResult driveChain(List snapshot, CacheContext context "CacheHandler returned null HandlerResult: " + current.getClass().getName()); } + if (result.decision() == null) { + // SPI 协议要求 HandlerResult 携带非 null decision,否则引擎无法分发控制流。 + throw new IllegalStateException( + "CacheHandler returned HandlerResult with null decision: " + + current.getClass().getName()); + } switch (result.decision()) { case CONTINUE: if (next.advanced()) { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java new file mode 100644 index 00000000..57d04f7c --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerResultProtocolTest.java @@ -0,0 +1,42 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; +import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * HandlerResult 与 ChainEngine 之间的 SPI 协议边界测试。 + */ +@DisplayName("HandlerResult Protocol Tests") +class HandlerResultProtocolTest { + + @Test + @DisplayName("引擎拒绝 null decision 并指名违规 handler") + void engine_rejectsNullDecisionWithNamedHandler() { + HandlerResult invalidResult = mock(HandlerResult.class); + when(invalidResult.decision()).thenReturn(null); + CacheHandler offendingHandler = context -> invalidResult; + + assertThatThrownBy(() -> new ChainEngine() + .execute(List.of(offendingHandler), testContext())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("null decision") + .hasMessageContaining(offendingHandler.getClass().getName()); + } + + private static CacheContext testContext() { + return CacheContext.of(CacheInput.builder() + .operation(CacheOperation.GET) + .cacheName("test-cache") + .redisKey("test:key") + .actualKey("test:key") + .build()); + } +} From 44a0b59f97b001b9f970bc11c42fda8e95243504 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:22:38 +0800 Subject: [PATCH 32/56] docs(changelog): roll up architecture remediation c1-c9 --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 372a8b5c..a2194227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -237,6 +237,56 @@ Current milestones: technical increment is **Bloom + TTL jitter + pluggable responsibility chain**. +### Architecture remediation (2026-09-22 review, c1–c9) + +- **One owner for failure reporting (c1)** — package-private `FailureReport` + emits the sanctioned WARN/ERROR plus paired DEBUG line for every failure site, + so callers state what failed instead of choosing log levels and re-deriving + the fingerprint rule; no `keyFingerprint` concatenation or hand-paired DEBUG + remains in `src/main`. Levels, count-once accounting, the failure metric + dimensions and the raw-key privacy rule are unchanged; message rendering + (field order and punctuation) is now produced by the one owner. +- **Metrics resolved at one non-null seam (c2)** — `resi-cache.metrics.enabled` + is read in exactly one place and handed to every caller as a non-null metrics + seam, with a no-op adapter for the disabled case; the opt-in is declared in + `additional-spring-configuration-metadata.json`. The key has no + `RedisProCacheProperties` field: it is fixed, and binding it would require a + tenth public nested type (`STABILITY.md` §4 churn) for an assembly detail. +- ⚠️ **Protection health is no longer gated by the metrics opt-in (c2)** — + `RedisCacheHealthIndicator` now reports Redis connectivity and protection + degradation regardless of `resi-cache.metrics.enabled`; previously the + unrelated metrics switch could suppress the indicator. +- **Observer order owned by the observer class (c3)** — the dispatch sort reads + a class-level `@Order` that is actually declared instead of a `@Bean`-method + annotation the factory never consulted; the documented hook protocol, + `beforeNode` and the scope-token types are unchanged. +- **Handler identity declared once (c4)** — order slot, protection disable name, + metric/log tag and the ordering requirement of each slot are declared + alongside `HandlerOrder` and resolved by `cache/HandlerIdentity`; emitted tag + values are unchanged, and a handler with no declared identity keeps the + previous class-simple-name tag. +- **One assembly root per boundary (c5)** — the runtime context and the operator + CLI each name the beans they own by class instead of regex package-scan + patterns, `resi-cache.enabled` is declared once, and the bean-backoff + invariant enumerates `@Bean` methods. +- **One projection feeds both operation views (c6)** — the AOP operation and the + policy view are derived from one `RedisCacheAttributes` instance per + annotation, so a new annotation field has one mapping site and the policy + lookup no longer depends on declaration order; the Spring/policy view split + itself is retained. +- **Unreachable degradation modes deleted (c7)** — dead null guards, the + `NullValueEncoder` wrapper and test-only factory surface are gone. The + engine-side rejection of a malformed `HandlerResult` is retained because + `STABILITY.md` §4 documents it. +- **TTL precedence in one module (c8)** — `TtlPolicy` owns the ordered + resolution and both defaults; every path keeps its previous effective TTL and + no default changed. See [`COMPATIBILITY.md`](./COMPATIBILITY.md) and + [`docs/REFERENCE.md`](docs/REFERENCE.md). +- **Synchronization lifecycle owned by its state (c9)** — the single-flight + lifecycle (enter → complete → exit → cleanup, exactly once) now lives with the + state it mutates and the seven-argument static re-entry is gone; lock + acquire/release order and failure paths are unchanged. + ### Fixed - **`resi-cache.serializer.*` properties were silently dropped by the From a0f0367629426969bdb397c66b5e48fef91b4646 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:27:22 +0800 Subject: [PATCH 33/56] docs: qualify the failure-report shape and the operator-root import claim --- docs/ARCHITECTURE.md | 5 +++-- .../cache/SerializationMigrationOperatorConfiguration.java | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 73e58a0c..7dfbcf0d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,8 +106,9 @@ current documented behavior in `COMPATIBILITY.md`. - `LoaderOrchestrator` owns the shared read → load → write-back protocol. A successful loaded value is returned even when write-back fails. - `FailureReport` owns the one failure-reporting shape: a WARN/ERROR carrying - only cacheName or the key fingerprint plus the exception type chain, paired - with a DEBUG line holding the full stack; `CacheErrorHandler` owns count-once + only cacheName or the key fingerprint, and — when the report carries a + throwable — its exception type chain plus a paired DEBUG line holding the full + stack. A report without a throwable emits the WARN only; `CacheErrorHandler` owns count-once reporting for chain failures on top of it, and the failure metric uses finite operation/kind/strategy dimensions. - `SecureJacksonRedisSerializer` owns whitelist-backed serialization and the diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java index ead3434f..cf6e00db 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationOperatorConfiguration.java @@ -8,7 +8,8 @@ * *

    只由 operator 入口 {@code SerializationMigrationCli} 导入;运行时装配根 * {@code RedisCacheAutoConfiguration} 按类排除本类,因此该边界不会进入运行时上下文。 - * 内部 bean 不带组件注解,由本类显式声明 —— 类改名会编译失败,而不是静默清空 CLI 上下文。 + * CLI 上下文不做组件扫描,所导入的 bean 一律由本类按类声明 —— 类改名会编译失败, + * 而不是静默清空 CLI 上下文。 */ @Configuration(proxyBeanMethods = false) @Import({ From 69e3c0634528b9f8d54ef9307f7cf896236c1665 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:43:36 +0800 Subject: [PATCH 34/56] fix(cache): resolve the metrics opt-in before touching the registry The disabled path resolved the MeterRegistry provider first, so an application with several non-primary registries could fail startup even though metrics were explicitly off, and the shared static no-op sink accumulated every context's meters for the life of the JVM. Read the opt-in first and hand out a per-resolution adapter instead. --- .../cache/redis/cache/ResolvedMetrics.java | 38 ++++++++++++++----- .../redis/cache/CacheFailureReporterTest.java | 4 +- ...edisProCacheConfigurationContractTest.java | 32 ++++++++++++++-- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java index 80bb32b2..a17e239b 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java @@ -11,26 +11,46 @@ * once, so assembly and tests cross the same check. * *

    Callers always get a non-null seam. When metrics are disabled, or the application has - * no {@code MeterRegistry} bean, the seam is the shared no-op adapter - * {@link #NOOP_REGISTRY} — a {@link CompositeMeterRegistry} that never gets a child - * registry, so every recorded sample lands nowhere. The disabled case is therefore not a - * {@code registry == null} re-check at each caller. + * no {@code MeterRegistry} bean, the seam is a {@link CompositeMeterRegistry} that never + * gets a child registry, so every recorded sample lands nowhere. The disabled case is + * therefore not a {@code registry == null} re-check at each caller. + * + *

    The opt-in is read before the provider is touched: with metrics disabled the + * application's registry beans are neither resolved nor instantiated, so an ambiguous + * {@code MeterRegistry} set cannot fail an assembly that explicitly turned metrics off. * * @param meterRegistry metrics seam, never {@code null} */ record ResolvedMetrics(MeterRegistry meterRegistry) { - /** No-op metrics seam used when metrics are disabled or unavailable. */ - static final MeterRegistry NOOP_REGISTRY = new CompositeMeterRegistry(); - private static final String METRICS_ENABLED_PROPERTY = "resi-cache.metrics.enabled"; static ResolvedMetrics resolve( @Nullable ObjectProvider meterRegistryProvider, Environment environment) { + if (!environment.getProperty(METRICS_ENABLED_PROPERTY, Boolean.class, false)) { + return new ResolvedMetrics(noOpAdapter()); + } MeterRegistry registry = meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable(); - boolean enabled = environment.getProperty(METRICS_ENABLED_PROPERTY, Boolean.class, false); - return new ResolvedMetrics(enabled && registry != null ? registry : NOOP_REGISTRY); + return new ResolvedMetrics(registry != null ? registry : noOpAdapter()); + } + + /** + * Nothing-publishing metrics seam, created per resolution. + * + *

    Deliberately not a shared instance: a {@link CompositeMeterRegistry} keeps every + * meter it is asked for in its own map, so one static sink would accumulate the meter + * ids and tag strings of every application context — and of every dynamically named + * cache — for the lifetime of the JVM. One adapter per resolution keeps that bounded by + * the context that owns the seam. + * + *

    {@code ponytail}: meters registered into this adapter are still allocated until the + * owning context closes. A zero-allocation seam would need a hand-written + * non-registering {@code MeterRegistry}; add one only if a long-lived context is + * observed accumulating disabled-path meters. + */ + private static MeterRegistry noOpAdapter() { + return new CompositeMeterRegistry(); } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java index 7c5ea887..eb0c9a2b 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java @@ -14,6 +14,7 @@ import java.util.stream.Collectors; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; /** @@ -84,7 +85,8 @@ void report_nullArgs_usesUnknownTag() { @Test @DisplayName("no-op seam → 不抛异常,且不向应用 registry 注册任何 meter") void noopRegistry_noOp() { - CacheFailureReporter noRegistry = new CacheFailureReporter(ResolvedMetrics.NOOP_REGISTRY); + CacheFailureReporter noRegistry = new CacheFailureReporter( + ResolvedMetrics.resolve(null, new MockEnvironment()).meterRegistry()); noRegistry.report(CacheOperation.PUT, FailureKind.REDIS, ErrorStrategy.FAIL_FAST); assertThat(registry.getMeters()).isEmpty(); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index c0234097..15c26f43 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -11,6 +11,7 @@ import io.github.davidhlp.spring.cache.redis.protection.bloom.filter.BloomIFilter; import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.lang.reflect.Method; import java.util.function.Consumer; @@ -88,8 +89,33 @@ void metricsEnabled_withoutMeterRegistry_keepsNoOpChoice() throws Exception { assertThat(context).hasNotFailed(); assertThat(context).doesNotHaveBean(MeterRegistry.class); assertThat(context).hasSingleBean(ResolvedMetrics.class); + MeterRegistry seam = context.getBean(ResolvedMetrics.class).meterRegistry(); + assertThat(seam).isInstanceOf(CompositeMeterRegistry.class); + assertThat(((CompositeMeterRegistry) seam).getRegistries()).isEmpty(); + }); + } + } + + @Test + void metricsDisabled_withAmbiguousMeterRegistries_stillAssembles() throws Exception { + try (org.springframework.boot.test.context.FilteredClassLoader classLoader = + new org.springframework.boot.test.context.FilteredClassLoader( + org.redisson.api.RedissonClient.class)) { + new ApplicationContextRunner() + .withClassLoader(classLoader) + .withConfiguration(AutoConfigurations.of(RedisCacheAutoConfiguration.class)) + .withBean("firstRegistry", MeterRegistry.class, SimpleMeterRegistry::new) + .withBean("secondRegistry", MeterRegistry.class, SimpleMeterRegistry::new) + .withBean(RedisProCacheWriter.class, + () -> org.mockito.Mockito.mock(RedisProCacheWriter.class)) + .withBean(RedisConnectionFactory.class, + () -> org.mockito.Mockito.mock(RedisConnectionFactory.class)) + .run(context -> { + // 未启用 metrics 时不解析 provider:多个非 primary MeterRegistry 不得让装配失败 + assertThat(context).hasNotFailed(); assertThat(context.getBean(ResolvedMetrics.class).meterRegistry()) - .isSameAs(ResolvedMetrics.NOOP_REGISTRY); + .isNotSameAs(context.getBean("firstRegistry")) + .isNotSameAs(context.getBean("secondRegistry")); }); } } @@ -334,8 +360,8 @@ void standardObservers_declareOrderOnTheirClass() { .as("observer factory must be a bean method") .isNotNull()); - // 顺序契约必须落在工厂 CacheHandlerChainFactory#observerOrder 真正读取的那一处 —— - // observer 类级 @Order,而非 @Bean 方法上的注解(工厂不看方法注解)。 + // 顺序契约落在 observer 类级 @Order —— Spring 解析注入列表时读取该注解; + // 工厂保持注入序,不再按实例可见的类级注解二次排序。 var observerClasses = observerMethods.stream() .map(java.lang.reflect.Method::getReturnType) .toList(); From 453100da8ede1bf23a7495b0c4855da789d9f841 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:43:38 +0800 Subject: [PATCH 35/56] fix(cache): register observers in the Spring-resolved order The factory re-sorted the injected observer list by a class-level @Order that an instance-level comparator can read, which moved observers ordered through Ordered, a @Bean-method @Order, a meta-annotation or a proxy behind the standard adapters. Keep the injected order and pin both the standard declaration and the unordered-observer position in tests. --- .../redis/cache/CacheHandlerChainFactory.java | 34 +++----- .../redis/cache/MDCStampChainObserver.java | 4 +- .../cache/RedisProCacheConfiguration.java | 5 +- .../cache/redis/cache/ChainObserverTest.java | 84 +++++++++++++++++-- 4 files changed, 92 insertions(+), 35 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java index 2c46e7ff..456af77c 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheHandlerChainFactory.java @@ -155,8 +155,10 @@ public CacheHandlerChain createChain() { return cachedChain; } - // 1) 装配 observer(单一装配点):由本工厂按 observer 类级 @Order 排序后注册, - // 不依赖 Spring 注入列表的顺序(注入顺序非本类的顺序契约)。 + // 1) 装配 observer(单一装配点):注册序即派发序,注入列表由 Spring 按其支持的 + // 顺序来源排好(@Order、Ordered、@Bean 方法注解、元注解/代理),本工厂不再 + // 二次排序 —— 实例级比较器只看得到类级注解,会把 Spring 认得而它看不到的 + // 顺序(如 @Bean 方法上的 @Order(0))丢掉。 // idempotent 由本方法的单例缓存 miss pattern 保证,首次 miss 后不会再进本块。 registerObserversOnce(); @@ -205,22 +207,20 @@ public CacheHandlerChain createChain() { /** * 注册注入的 observer 到 Engine — 单一装配点。 * - *

    顺序由 observer 类级 {@code @Order} 单一拥有,本方法按 {@link #observerOrder} - * 升序排序(标准 MDC→DebugLog→Timer→FiredCounter 各带 {@code @Order(1..4)}; - * 未标注的自定义 observer 取 {@link Integer#MAX_VALUE} 排在最后),再按类型去重 - * (同名同 tag counter 重复注册幂等,但 observer 实例重复注册会双计 — 去重保证 - * 每个 observer 类恰好注册一次),随后按序 addObserver。注册顺序即 - * {@code STABILITY.md §4} 承诺的 observer 执行顺序。 + *

    注册顺序即 {@code STABILITY.md §4} 承诺的 observer 执行顺序,也就是 Spring 解析 + * 注入列表时给出的顺序(observation order = registration order)。标准 + * MDC→DebugLog→Timer→FiredCounter 由各 observer 类级 {@code @Order(1..4)} 声明,Spring + * 与用户 observer 的其他顺序来源({@code Ordered}、{@code @Bean} 方法上的 {@code @Order}、 + * 元注解/代理)同样由 Spring 解析 —— 工厂因此保持注入序,不再按类级注解二次排序。按类型 + * 去重(同名同 tag counter 重复注册幂等,但 observer 实例重复注册会双计 — 去重保证 + * 每个 observer 类恰好注册一次),随后按序 addObserver。 * *

    registry 缺失时:MDC/DebugLog 无 registry 依赖;Timer/FiredCounter * observer 内部 lazy 检测,registry 缺失时全 no-op。 */ private void registerObserversOnce() { Set> seen = new HashSet<>(); - List sorted = observers.stream() - .sorted(Comparator.comparingInt(this::observerOrder)) - .toList(); - for (ChainObserver observer : sorted) { + for (ChainObserver observer : observers) { if (observer == null || !seen.add(observer.getClass())) { continue; } @@ -229,16 +229,6 @@ private void registerObserversOnce() { } } - /** - * observer 顺序的唯一真值读取点:读 observer 类上的 {@code @Order}(而非 {@code @Bean} - * 方法上的),因此 {@link #registerObserversOnce} 的排序对标准与自定义 observer 均生效。 - */ - private int observerOrder(ChainObserver observer) { - org.springframework.core.annotation.Order order = - observer.getClass().getAnnotation(org.springframework.core.annotation.Order.class); - return order != null ? order.value() : Integer.MAX_VALUE; - } - /** * 解析 protection 机制禁用集合 — 处理两条独立路径,追加到 {@code disabled} 集合: *

      diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java index 5c1276db..5c4c3b8f 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java @@ -36,8 +36,8 @@ */ @Slf4j // 标准 observer 执行顺序由类级 @Order 单一拥有(MDC→DebugLog→Timer→FiredCounter): -// 工厂 CacheHandlerChainFactory#observerOrder 读此注解排序,故本 observer 先 stamp requestId, -// ChainDebugLogChainObserver 才能在 afterNode 读到 MDC 中的 id。 +// Spring 注入 observer 列表时按同一注解排序,工厂保持注入序 —— 故本 observer 先 stamp +// requestId,ChainDebugLogChainObserver 才能在 afterNode 读到 MDC 中的 id。 @Order(1) final class MDCStampChainObserver implements ChainObserver { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java index 0c6eb24a..e9bc640d 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfiguration.java @@ -41,8 +41,9 @@ class RedisProCacheConfiguration { * 由 {@link CacheHandlerChainFactory} 单一装配点注入 Engine。 * *

      执行顺序(MDC → DebugLog → Timer → FiredCounter)由 observer 类自身的 - * {@code @Order} 单一声明(见各 observer 类);工厂 {@code observerOrder} 读取该 - * 类级注解排序,故 bean 方法不再重复声明。MDC 先 stamp,DEBUG log 再读 requestId, + * {@code @Order} 单一声明(见各 observer 类),Spring 注入列表时据此排序,工厂保持 + * 注入序,故 bean 方法不再重复声明。用户 observer 用 {@code Ordered} 或 {@code @Bean} + * 方法上的 {@code @Order} 表达的顺序同样生效。MDC 先 stamp,DEBUG log 再读 requestId, * Timer/FiredCounter 最后打点。registry 由 {@link ResolvedMetrics} 单一决议; * metrics 未启用时它是 no-op seam,Timer/FiredCounter observer 照常装配。 */ diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index d6e637b1..3aba1ebc 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.slf4j.MDC; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.annotation.Order; import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; @@ -55,6 +56,11 @@ void setUp() { }; } + /** metrics 未启用时的 no-op seam —— 走生产同一条解析路径。 */ + private static MeterRegistry noOpSeam() { + return ResolvedMetrics.resolve(null, new MockEnvironment()).meterRegistry(); + } + @Nested @DisplayName("MDCStampChainObserver") class MdcStampTests { @@ -116,7 +122,7 @@ class TimerTests { @Test @DisplayName("no-op seam 时节点计时不落任何出口,不抛异常") void noopSeam_noOp() { - ChainObserver observer = new ChainTimerChainObserver(ResolvedMetrics.NOOP_REGISTRY); + ChainObserver observer = new ChainTimerChainObserver(noOpSeam()); Object scopeToken = observer.onNodeStart(handler, ctx); @@ -236,7 +242,7 @@ class FiredCounterTests { @Test @DisplayName("no-op seam → afterNode 自增不落任何出口,不抛异常") void nullRegistry_noOp() { - ChainObserver observer = new FiredCounterChainObserver(ResolvedMetrics.NOOP_REGISTRY); + ChainObserver observer = new FiredCounterChainObserver(noOpSeam()); observer.afterNode(handler, ctx, HandlerResult.continueChain()); // 无异常即可 } @@ -264,19 +270,21 @@ void afterNode_incrementsPerHandlerType() { class ObserverOrderTests { /** - * 工厂按 observer 类级 {@code @Order} 注册,Engine 依注册序派发每个 hook。故意以 - * 逆序注入,证明生效顺序来自 @Order 而非注入顺序:若排序退化为 no-op(注解不在类上), - * beforeNode 将以 [third, first, second] 触发,断言失败。 + * 注册序即派发序。注入列表已由 Spring 按其支持的顺序来源排好({@code @Order}、 + * {@code Ordered}、{@code @Bean} 方法注解、元注解/代理);工厂保持注入序, + * 不再按实例可见的类级注解二次排序。 */ @Test - @DisplayName("observers dispatch in class-level @Order order across a chain run") - void createChain_dispatchesByOrderNotInjectionOrder() { + @DisplayName("factory keeps the Spring-resolved injected order across a chain run") + void createChain_preservesInjectedOrder() { RedisProCacheProperties properties = mock(RedisProCacheProperties.class); List sequence = new ArrayList<>(); - List injected = List.of( + List injected = new ArrayList<>(List.of( new ThirdOrderObserver(sequence), new FirstOrderObserver(sequence), - new SecondOrderObserver(sequence)); + new SecondOrderObserver(sequence))); + // Spring 注入前的排序动作:类级 @Order 由同一比较器解析 + AnnotationAwareOrderComparator.sort(injected); CacheHandlerChain chain = new CacheHandlerChainFactory( List.of(new SingleNodeHandler()), properties, @@ -287,6 +295,54 @@ void createChain_dispatchesByOrderNotInjectionOrder() { assertThat(sequence).containsExactly("first", "second", "third"); } + /** + * 回归守卫:顺序来自 Spring 能识别、而实例级比较器看不到的来源({@code @Bean} 方法上的 + * {@code @Order}、{@code Ordered}、代理/元注解)时,observer 必须保留 Spring 给它的位置, + * 不能被工厂挤到带类级 {@code @Order} 的 observer 之后。 + */ + @Test + @DisplayName("an observer without class-level @Order keeps its injected position") + void createChain_preservesInjectedPositionOfUnorderedObserver() { + RedisProCacheProperties properties = mock(RedisProCacheProperties.class); + List sequence = new ArrayList<>(); + List injected = List.of( + new UnorderedObserver(sequence), + new FirstOrderObserver(sequence)); + + CacheHandlerChain chain = new CacheHandlerChainFactory( + List.of(new SingleNodeHandler()), properties, + ResolvedMetrics.resolve(null, new MockEnvironment()), new ChainEngine(), injected) + .createChain(); + chain.execute(ctx); + + assertThat(sequence).containsExactly("unordered", "first"); + } + + /** + * 标准 observer 的相对顺序由类级 {@code @Order} 单一声明;Spring 注入列表时按同一 + * 比较器排序,因此工厂不再二次排序后 MDC→DebugLog→Timer→FiredCounter 依旧成立。 + */ + @Test + @DisplayName("standard observers declare an ascending class-level @Order") + void standardObservers_declareAscendingOrder() { + MeterRegistry seam = noOpSeam(); + List standard = new ArrayList<>(List.of( + new FiredCounterChainObserver(seam), + new ChainTimerChainObserver(seam), + new MDCStampChainObserver(), + new ChainDebugLogChainObserver())); + + AnnotationAwareOrderComparator.sort(standard); + + assertThat(standard) + .extracting(observer -> observer.getClass().getSimpleName()) + .containsExactly( + "MDCStampChainObserver", + "ChainDebugLogChainObserver", + "ChainTimerChainObserver", + "FiredCounterChainObserver"); + } + private static final class SingleNodeHandler implements CacheHandler { @Override public HandlerResult handle(CacheContext context) { @@ -323,5 +379,15 @@ public void beforeNode(CacheHandler handler, CacheContext context) { sequence.add("third"); } } + + /** 无类级 @Order:顺序由 Spring 的其他来源决定,工厂必须保持注入位置。 */ + private static final class UnorderedObserver implements ChainObserver { + private final List sequence; + UnorderedObserver(List sequence) { this.sequence = sequence; } + @Override + public void beforeNode(CacheHandler handler, CacheContext context) { + sequence.add("unordered"); + } + } } } From 5a02c38ed528f5ec0b8504373f2e433655be0c0c Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:43:39 +0800 Subject: [PATCH 36/56] docs(changelog): state the metrics and observer-order follow-ups --- CHANGELOG.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2194227..6cbc08c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -248,7 +248,10 @@ Current milestones: (field order and punctuation) is now produced by the one owner. - **Metrics resolved at one non-null seam (c2)** — `resi-cache.metrics.enabled` is read in exactly one place and handed to every caller as a non-null metrics - seam, with a no-op adapter for the disabled case; the opt-in is declared in + seam; when the opt-in is off (or no `MeterRegistry` bean exists) that seam + publishes nothing and the application's registry beans are not even resolved, + so an ambiguous registry set cannot fail an assembly that disabled metrics. + The opt-in is declared in `additional-spring-configuration-metadata.json`. The key has no `RedisProCacheProperties` field: it is fixed, and binding it would require a tenth public nested type (`STABILITY.md` §4 churn) for an assembly detail. @@ -256,9 +259,12 @@ Current milestones: `RedisCacheHealthIndicator` now reports Redis connectivity and protection degradation regardless of `resi-cache.metrics.enabled`; previously the unrelated metrics switch could suppress the indicator. -- **Observer order owned by the observer class (c3)** — the dispatch sort reads - a class-level `@Order` that is actually declared instead of a `@Bean`-method - annotation the factory never consulted; the documented hook protocol, +- **Observer order owned by the observer class (c3)** — the four standard + observers declare `@Order(1..4)` on the class instead of on their `@Bean` + methods, and the factory registers observers in the Spring-resolved injection + order instead of re-sorting by an annotation only it could see; observers + ordered through `Ordered`, a `@Bean`-method `@Order`, a meta-annotation or a + proxy keep the position Spring gave them. The documented hook protocol, `beforeNode` and the scope-token types are unchanged. - **Handler identity declared once (c4)** — order slot, protection disable name, metric/log tag and the ordering requirement of each slot are declared From 96ee5708b23f51d4d8f9314819b244378070452a Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 11:52:41 +0800 Subject: [PATCH 37/56] test(cache): pin the injected order of the standard observers The class-level @Order must reach Spring's list injection, not just an instance-level comparator: if it stopped being honoured, the order would fall back to bean-name order and the debug log would read the request id before it is stamped. --- ...edisProCacheConfigurationContractTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index 15c26f43..bd252716 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -400,6 +400,38 @@ void everyDefaultBeanDeclaresBackoff() { .isNotNull()); } + @Test + void standardObservers_injectInDeclaredOrder() throws Exception { + try (org.springframework.boot.test.context.FilteredClassLoader classLoader = + new org.springframework.boot.test.context.FilteredClassLoader( + org.redisson.api.RedissonClient.class)) { + new ApplicationContextRunner() + .withClassLoader(classLoader) + .withConfiguration(AutoConfigurations.of(RedisCacheAutoConfiguration.class)) + .withBean(RedisProCacheWriter.class, + () -> org.mockito.Mockito.mock(RedisProCacheWriter.class)) + .withBean(RedisConnectionFactory.class, + () -> org.mockito.Mockito.mock(RedisConnectionFactory.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + // 类级 @Order 必须真的落到 Spring 的注入顺序上(工厂保持该顺序); + // 若注解不生效,顺序会退化成 bean 名序(DebugLog 先于 MDC)。 + assertThat(context + .getBeanProvider( + io.github.davidhlp.spring.cache.redis.chain.observer + .ChainObserver.class) + .orderedStream() + .map(observer -> observer.getClass().getSimpleName()) + .toList()) + .containsExactly( + "MDCStampChainObserver", + "ChainDebugLogChainObserver", + "ChainTimerChainObserver", + "FiredCounterChainObserver"); + }); + } + } + private ConditionalOnMissingBean conditionOn(String methodName) { for (Method method : RedisProCacheConfiguration.class.getDeclaredMethods()) { if (method.getName().equals(methodName)) { From 9024e0927300709af26d08a3f210d95f7ef2cbab Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:12:48 +0800 Subject: [PATCH 38/56] refactor(cache): type the observer dispatch and remove the beforeNode hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observer dispatch helper now carries each level's own result type (CacheResult at chain level, HandlerResult at node level), so the two downcasts in ChainEngine are gone; scope tokens stay Object, paired back positionally per observer. MDCStampChainObserver and ChainTimerChainObserver no longer re-check their own token's runtime type: the engine's index pairing makes the token their own reference, so the private token type is a cast, not a defensive instanceof. beforeNode had zero production implementers (grep over src/main: 12 hits, all javadoc plus the hook declaration and its dispatch call, no @Override in any adapter); it is removed from the SPI and the engine's node loop is onNodeStart -> handler.handle -> afterNode -> onNodeEnd. Order and content of every remaining dispatch are unchanged. BREAKING CHANGE: ChainObserver.beforeNode(CacheHandler, CacheContext) is removed. Move node pre-execution work to onNodeStart (or afterNode when the evaluated result is needed); per-call state moves with the documented onNodeStart/onNodeEnd token pairing. STABILITY.md §4 Observers records the migration path. Co-Authored-By: Claude Code --- STABILITY.md | 21 +++++++++- .../redis/cache/AbstractCacheHandler.java | 1 - .../cache/ChainDebugLogChainObserver.java | 2 +- .../spring/cache/redis/cache/ChainEngine.java | 41 +++++++++++-------- .../redis/cache/ChainTimerChainObserver.java | 8 +++- .../redis/cache/MDCStampChainObserver.java | 10 +++-- .../redis/chain/observer/ChainObserver.java | 22 ++++------ .../cache/redis/cache/ChainEngineTest.java | 16 +++----- .../cache/redis/cache/ChainObserverTest.java | 12 ++++-- 9 files changed, 76 insertions(+), 57 deletions(-) diff --git a/STABILITY.md b/STABILITY.md index a48fd80a..0ea75c60 100644 --- a/STABILITY.md +++ b/STABILITY.md @@ -130,17 +130,34 @@ custom implementation must satisfy. ### Observers 1. **Hook order** per chain execution: `onChainStart` → per node - [`onNodeStart` → `beforeNode` → `handler.handle(context, next)` → + [`onNodeStart` → `handler.handle(context, next)` → `afterNode` → `onNodeEnd`] → `onChainEnd`. Multiple observers run in registration (`@Order`) order for every hook. 2. **Scope tokens**: each `on*Start` returns a per-call token the engine pairs back to the same observer's `on*End` in a `finally` block (on handler exception `onNodeEnd` receives a `null` result — recover the token, do not fabricate decisions). Tokens carry per-call state; observers - must be thread-safe and stateless between calls. + must be thread-safe and stateless between calls. The engine pairs by + observer registration index, so the token an `on*End` hook receives is + always the reference returned by *that* observer's matching `on*Start`: an + observer may cast its own token to its private token type without a + runtime type check. 3. **Exception isolation**: observer hook failures are caught and logged by the engine; they never change chain control flow. +⚠️ **BREAKING — `beforeNode` removed (0.x)**: the SPI hook +`ChainObserver.beforeNode(CacheHandler, CacheContext)` no longer exists; it had +zero production implementers and no other hook was renamed, retyped or +reordered. Migration from an implementation that overrode it: + +- node pre-execution work (DEBUG log / counter increments / start markers) → + move the body to `onNodeStart`, returning `null` when the observer keeps no + per-call state; +- work that needs the evaluated result → move the body to `afterNode`; +- per-call state that a matching hook must recover → return it from + `onNodeStart` and read it back in `onNodeEnd` (the documented token pairing + above); the engine still calls `onNodeEnd` from a `finally` block. + ### Context - `CacheContext` exposes a read-only `InputView` (operation, cacheName, diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java index 61b684d7..037facaf 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java @@ -158,7 +158,6 @@ protected void safeIncrementSemantic() { *

      Engine 已在调用本方法前完成: *

        *
      • {@code skipRemaining} 短路检测(isSkipRemaining 返 true 时根本不调本方法)
      • - *
      • observer.beforeNode(DEBUG / fired counter)
      • *
      * Engine 在本方法返回后做: *
        diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java index 63404c32..0ae79a95 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainDebugLogChainObserver.java @@ -28,7 +28,7 @@ * 则 requestId 为 null,日志降级为不含 id 形式(仅影响 DEBUG 可读性,不影响功能)。 * *

        线程安全:MDC 是 ThreadLocal,本类在调用方线程上读取(Engine 串行调用 - * beforeNode → handler → afterNode),无共享状态。 + * onNodeStart → handler → afterNode),无共享状态。 */ @Slf4j @Order(2) // 执行顺序单一真值源=类级 @Order,见 MDCStampChainObserver 注释 diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java index 8dcc3123..1e5695bd 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngine.java @@ -28,13 +28,15 @@ *

          *
        • {@link FlowControl#CONTINUE} — 推进到下一个 handler;无下一个则返回当前 result
        • *
        • {@link FlowControl#SKIP_ALL} — 物化 {@code context.markSkipRemaining()}, - * 返回 result,下游 handler 短路(由 beforeNode 检测 skipRemaining 状态)
        • + * 返回 result,下游 handler 短路(由 {@link #driveChain} 每轮检测 + * {@code isSkipRemaining()} 状态) *
        • {@link FlowControl#TERMINATE} — 直接返回 result
        • *
        * *

        观测编排:Engine 在链入口调用所有 observer 的 * {@link ChainObserver#onChainStart(CacheContext)},节点前后调用 - * {@link ChainObserver#beforeNode}/{@link ChainObserver#afterNode}, + * {@link ChainObserver#onNodeStart(CacheHandler, CacheContext)} / + * {@link ChainObserver#afterNode(CacheHandler, CacheContext, HandlerResult)}, * 链出口调用 {@link ChainObserver#onChainEnd(CacheContext, Object, CacheResult)}; * 正常完成时传最终结果,主路径抛异常时传 {@code null}(表示未产生结果)。 * Observer 实现以 default no-op 形式提供(见 {@link ChainObserver}), @@ -104,7 +106,8 @@ public void addObserver(ChainObserver observer) { *

          *
        1. 快照当前 handler 链;空链打 WARN(由 ChainLifecycle 仍跑 around-hook 配对)
        2. *
        3. 所有 observer.onChainStart — ChainLifecycle 入口
        4. - *
        5. 节点循环:beforeNode → handler.handle(ctx, continuation) → afterNode → decision switch — driveChain
        6. + *
        7. 节点循环:onNodeStart → handler.handle(ctx, continuation) → afterNode + * → onNodeEnd → decision switch — driveChain
        8. *
        9. post-process 遍历 — ChainLifecycle 内部
        10. *
        11. 所有 observer.onChainEnd(即使主路径异常也调用) — ChainLifecycle finally 守护
        12. *
        @@ -293,7 +296,7 @@ private final class ChainLifecycle { *

        driveChain 抛出的异常继续向上冒泡;onChainEnd 由 finally 守护保证触发。 */ CacheResult run() { - ObserverDispatch observation = new ObserverDispatch(); + ObserverDispatch observation = new ObserverDispatch<>(); Object[] scopeTokens = observation.start( "onChainStart", observer -> observer.onChainStart(context)); CacheResult mainResult = null; @@ -311,27 +314,24 @@ CacheResult run() { scopeTokens, mainResult, (observer, token, result) -> - observer.onChainEnd(context, token, (CacheResult) result)); + observer.onChainEnd(context, token, result)); } return mainResult; } /** - * 单节点调用:onNodeStart → beforeNode → handler.handle(ctx, next) → afterNode → + * 单节点调用:onNodeStart → handler.handle(ctx, next) → afterNode → * onNodeEnd。handler 异常仍向调用方冒泡;token 化的 onNodeEnd 由 finally * 配对,避免 around-node observer 泄漏调用状态。 */ HandlerResult invokeNode(CacheHandler handler, CacheContext nodeContext, ChainContinuation next) { // 每个节点单独拍 observer 快照,保持节点间注册变更隔离语义。 - ObserverDispatch observation = new ObserverDispatch(); + ObserverDispatch observation = new ObserverDispatch<>(); Object[] scopeTokens = observation.start( "onNodeStart", observer -> observer.onNodeStart(handler, nodeContext)); HandlerResult result = null; try { - observation.each( - "beforeNode", - observer -> observer.beforeNode(handler, nodeContext)); result = handler.handle(nodeContext, next); HandlerResult completedResult = result; observation.each( @@ -343,9 +343,8 @@ HandlerResult invokeNode(CacheHandler handler, CacheContext nodeContext, "onNodeEnd", scopeTokens, result, - (observer, token, completedResult) -> - observer.onNodeEnd( - handler, nodeContext, token, (HandlerResult) completedResult)); + (observer, token, nodeResult) -> + observer.onNodeEnd(handler, nodeContext, token, nodeResult)); } } @@ -375,8 +374,14 @@ private void runPostProcess(CacheResult mainResult) { /** * One observer snapshot plus shared positional token/error protocol for either * chain-level or node-level dispatch. + * + *

        {@code R} is the dispatch level's own result type — + * {@link CacheResult} at chain level, {@link HandlerResult} at node level — + * so the end hook receives its result without a downcast. Scope tokens stay + * {@code Object}: they are observer-private per-call state, paired back + * positionally to the observer that returned them. */ - private final class ObserverDispatch { + private final class ObserverDispatch { private final List observerList = List.copyOf(observers); @@ -403,8 +408,8 @@ void each(String hookName, ObserverHook hook) { } } - void finish(String hookName, Object[] scopeTokens, Object result, - ObserverEndHook hook) { + void finish(String hookName, Object[] scopeTokens, R result, + ObserverEndHook hook) { for (int i = 0; i < observerList.size(); i++) { ChainObserver observer = observerList.get(i); try { @@ -431,8 +436,8 @@ private interface ObserverHook { } @FunctionalInterface - private interface ObserverEndHook { - void invoke(ChainObserver observer, Object scopeToken, Object result); + private interface ObserverEndHook { + void invoke(ChainObserver observer, Object scopeToken, T result); } } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java index ddf4416a..97318465 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java @@ -52,9 +52,15 @@ public Object onNodeStart(CacheHandler handler, CacheContext context) { @Override public void onNodeEnd(CacheHandler handler, CacheContext context, Object scopeToken, HandlerResult result) { - if (result == null || !(scopeToken instanceof TimerScope scope)) { + if (result == null || scopeToken == null) { + // 故障节点没有 HandlerResult,不伪造 decision;token 为 null 仅当本人 + // onNodeStart 抛异常(Engine 不产生 token),同样无样本可记录。 return; } + // Engine 按 observer index 严格配对回传,故 token 必然是本人 onNodeStart 返回的 + // TimerScope(见 ChainObserver 的 scope token 机制说明)—— 协议保证的类型, + // 不做防御性 instanceof 重检。 + TimerScope scope = (TimerScope) scopeToken; TimerKey key = new TimerKey( CacheHandlerChain.handlerTag(handler), result.decision().name(), diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java index 5c4c3b8f..f1222baa 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/MDCStampChainObserver.java @@ -53,12 +53,14 @@ public Object onChainStart(CacheContext context) { @Override public void onChainEnd(CacheContext context, Object scopeToken, CacheResult result) { - // scopeToken 即 onChainStart 返回的 MdcScope 实例,无 cast 之 cast - // —— instanceof 模式匹配恢复 previousRequestId 字段 - if (!(scopeToken instanceof MdcScope scope)) { - // 防御性:Engine 协议保证 token 类型匹配,理论不可达;失败则不恢复(不污染调用方 MDC) + // Engine 按 observer index 严格配对回传,故 token 必然是本人 onChainStart 返回的 + // MdcScope(见 ChainObserver 的 scope token 机制说明)—— 协议保证的类型, + // 不做防御性 instanceof 重检。token 为 null 仅当本人 onChainStart 抛异常 + // (Engine 不产生 token),此时无原值可恢复。 + if (scopeToken == null) { return; } + MdcScope scope = (MdcScope) scopeToken; if (scope.previousRequestId() == null) { MDC.remove(CacheHandlerChain.MDC_REQUEST_ID_KEY); } else { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/chain/observer/ChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/chain/observer/ChainObserver.java index 8ce53ce2..f15bdacb 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/chain/observer/ChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/chain/observer/ChainObserver.java @@ -22,7 +22,6 @@ *

      • 每节点循环: *
          *
        • {@link #onNodeStart(CacheHandler, CacheContext)} — 节点 around-hook 起点
        • - *
        • {@link #beforeNode(CacheHandler, CacheContext)} — per-node 前置
        • *
        • handler.handle(ctx)
        • *
        • {@link #afterNode(CacheHandler, CacheContext, HandlerResult)} — 成功返回后的 per-node 后置
        • *
        • {@link #onNodeEnd(CacheHandler, CacheContext, Object, HandlerResult)} — @@ -41,10 +40,15 @@ * ChainTimer → per-node startNanos),Engine 在对应 end hook 配对回传。observer * 状态机完全自承,新 observer 零字符串键漂移风险,Engine 不感知 observer 内部协议。 * + *

          Engine 按 observer 注册 index 严格配对 start/end token —— 同一个 end hook + * 收到的 token 必然是该 observer 自己的 start hook 在同一调用中返回的引用 + * (跨 observer 不混淆),因此 observer 可以把自己的 token cast 回它私有的 + * token 类型而无需运行时类型重检。 + * *

          所有钩子默认 no-op;observability 实现(Mdc / Timer / Counter / DebugLog) * 各自只 override 关心的钩子,正交组合。{@code aroundChain} 关注点(MDC / Timer) * 必须在 {@code onChainStart} 配对,{@code perNode} 关注点(counter / log)只在 - * before/afterNode 触发。新增 Observation Span 时只需新增 + * {@code afterNode} 触发。新增 Observation Span 时只需新增 * {@code SpanObserver implements ChainObserver},Engine 与所有 handler 零修改 * — 这是本 seam 的核心 leverage 兑现。 * @@ -55,7 +59,7 @@ public interface ChainObserver { /** * 链入口 hook。Engine 在 stamp MDC / 启动 Timer 之后、第一次 - * {@code beforeNode} 之前调用。典型实现:MDCStamp / Timer 启动。 + * {@code onNodeStart} 之前调用。典型实现:MDCStamp / Timer 启动。 * *

          返回值:本 observer 的 per-call 状态,Engine 在 * {@link #onChainEnd} 配对回传。无状态 observer 返回 {@code null}。 @@ -114,18 +118,6 @@ default void onNodeEnd(CacheHandler handler, CacheContext context, // 默认 no-op } - /** - * 节点前置 hook。Engine 在调用 {@code handler.handle(ctx)} 之前调用, - * 即:{@code beforeNode} → {@code handler.handle(ctx)} → {@code afterNode}。 - * 典型实现:DEBUG log / fired counter 自增。 - * - * @param handler 即将被求值的 handler - * @param context 链上下文 - */ - default void beforeNode(CacheHandler handler, CacheContext context) { - // no-op - } - /** * 节点后置 hook。Engine 在 {@code handler.handle(ctx)} 返回之后调用, * 携带求值结果。Engine 不会在 handler 抛异常时调用本钩子 —— 异常冒泡 diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngineTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngineTest.java index 06eb7e2e..bf5617cf 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngineTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainEngineTest.java @@ -164,7 +164,7 @@ void aroundChain_andPerNode_calledInOrder() { engine.execute(snapshot, newCtx()); assertThat(observer.events).containsExactly( - "onChainStart", "onNodeStart", "beforeNode", "afterNode", + "onChainStart", "onNodeStart", "afterNode", "onNodeEnd", "onChainEnd"); } @@ -249,9 +249,9 @@ void continuation_advancesRemainderWithoutDuplicatingAroundChain() { // 嵌套推进不重复 stamp / record assertThat(observer.events).containsExactly( "onChainStart", - "onNodeStart", "beforeNode", // h0 进入 - "onNodeStart", "beforeNode", "afterNode", "onNodeEnd", // h1(嵌套) - "onNodeStart", "beforeNode", "afterNode", "onNodeEnd", // h2(嵌套) + "onNodeStart", // h0 进入 + "onNodeStart", "afterNode", "onNodeEnd", // h1(嵌套) + "onNodeStart", "afterNode", "onNodeEnd", // h2(嵌套) "afterNode", "onNodeEnd", // h0 退出 "onChainEnd"); } @@ -379,7 +379,6 @@ void observerThrows_swallowed_mainChainUnaffected() { } @Override public Object onNodeStart(CacheHandler h, CacheContext context) { throw new RuntimeException("node start boom"); } @Override public void onNodeEnd(CacheHandler h, CacheContext context, Object token, HandlerResult r) { throw new RuntimeException("node end boom"); } - @Override public void beforeNode(CacheHandler h, CacheContext context) { throw new RuntimeException("before boom"); } @Override public void afterNode(CacheHandler h, CacheContext context, HandlerResult r) { throw new RuntimeException("after boom"); } }; engine.addObserver(throwing); @@ -636,7 +635,7 @@ public boolean requiresPostProcess(CacheContext context) { // ==================== 测试用 observer(替换 mock) ==================== /** - * 录制 4 个钩子调用顺序的 ChainObserver — 替换 mock 验证。 + * 录制 5 个钩子调用顺序的 ChainObserver — 替换 mock 验证。 * *

          为什么用真实 observer 而非 mock:onChainStart 返回 Object 时, * {@code inOrder.verify(observer).onChainStart(any())} 这种 mock-based 验证 @@ -658,11 +657,6 @@ public Object onNodeStart(CacheHandler handler, CacheContext context) { return null; } - @Override - public void beforeNode(CacheHandler handler, CacheContext context) { - events.add("beforeNode"); - } - @Override public void afterNode(CacheHandler handler, CacheContext context, HandlerResult result) { events.add("afterNode"); diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index 3aba1ebc..64f53fc1 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -355,8 +355,9 @@ private static final class FirstOrderObserver implements ChainObserver { private final List sequence; FirstOrderObserver(List sequence) { this.sequence = sequence; } @Override - public void beforeNode(CacheHandler handler, CacheContext context) { + public Object onNodeStart(CacheHandler handler, CacheContext context) { sequence.add("first"); + return null; } } @@ -365,8 +366,9 @@ private static final class SecondOrderObserver implements ChainObserver { private final List sequence; SecondOrderObserver(List sequence) { this.sequence = sequence; } @Override - public void beforeNode(CacheHandler handler, CacheContext context) { + public Object onNodeStart(CacheHandler handler, CacheContext context) { sequence.add("second"); + return null; } } @@ -375,8 +377,9 @@ private static final class ThirdOrderObserver implements ChainObserver { private final List sequence; ThirdOrderObserver(List sequence) { this.sequence = sequence; } @Override - public void beforeNode(CacheHandler handler, CacheContext context) { + public Object onNodeStart(CacheHandler handler, CacheContext context) { sequence.add("third"); + return null; } } @@ -385,8 +388,9 @@ private static final class UnorderedObserver implements ChainObserver { private final List sequence; UnorderedObserver(List sequence) { this.sequence = sequence; } @Override - public void beforeNode(CacheHandler handler, CacheContext context) { + public Object onNodeStart(CacheHandler handler, CacheContext context) { sequence.add("unordered"); + return null; } } } From 05bf2515d0cb94b1ac1a6644c62009201b7f829b Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:20:50 +0800 Subject: [PATCH 39/56] fix(cache): resolve an unset annotation ttl to the configured default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ttl` attribute of `@RedisCacheable`/`@RedisCachePut` defaulted to 60 seconds and `TtlPolicy` reads "policy ttl > 0 wins", so an annotated method that never set `ttl` took 60 s and pre-empted the configured cache TTL. Re-point the unset sentinel at 0 — the encoding `resolve` already reads as "no method-level declaration" and the one `@RedisCacheEvict.ttl()` already uses — so an unset attribute falls through to `resi-cache.default-ttl` (30m unless overridden, per-cache `caches.*.ttl` still applies). Collapse `RedisCachePutOperation.Builder.ttl` (60) onto the same sentinel as `RedisCacheableOperation`/`RedisCacheEvictOperation` (0), leaving the annotation as the only place a method-level TTL can be declared. Delete `TtlPolicy.DEFAULT_TTL_SECONDS` and the branch that applied it when the Duration parameter is null. That branch is not reachable on a write path: TtlHandler only handles PUT/PUT_IF_ABSENT, whose Duration SDR 4.0 computes from `RedisCacheConfiguration`'s `TtlFunction` — `persistent()` (i.e. `Duration.ZERO`, not null) when no TTL is configured, and `entryTtl` rejects null — while ResiCache's own null-TTL writes are GET/REMOVE/CLEAN. A null parameter now means the same thing SDR's own writer means by it (no expiry): permanent, like zero and negative. `TtlPolicy` keeps the precedence visible as annotation > parameter (the configured default as Spring computes it) > permanent. Co-Authored-By: Claude Code --- .../cache/redis/annotation/RedisCachePut.java | 7 +- .../redis/annotation/RedisCacheable.java | 7 +- .../redis/cache/RedisCachePutOperation.java | 3 +- .../spring/cache/redis/cache/TtlPolicy.java | 39 +++--- .../cache/redis/cache/TtlHandlerTest.java | 6 +- .../cache/redis/cache/TtlPolicyTest.java | 119 +++++++++++++----- 6 files changed, 120 insertions(+), 61 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java index 35e545e8..844bf95b 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java @@ -67,8 +67,13 @@ /** * 缓存过期时间(秒). + * + *

          {@code 0}(即未设置)表示不作方法级 TTL 声明:该方法的条目改用 cache 级 + * {@code resi-cache.default-ttl}(默认 30 分钟,{@code caches.*.ttl} 可覆盖)。 + * 只有大于 0 的值才覆盖该配置默认值。与 {@link RedisCacheEvict#ttl()} 的未设置 + * 编码一致。 */ - long ttl() default 60; + long ttl() default 0; /** * 缓存值的声明类型(兼容性元数据). diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java index 62f1e8f3..67b39006 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java @@ -70,8 +70,13 @@ /** * 缓存过期时间(秒). + * + *

          {@code 0}(即未设置)表示不作方法级 TTL 声明:该方法的条目改用 cache 级 + * {@code resi-cache.default-ttl}(默认 30 分钟,{@code caches.*.ttl} 可覆盖)。 + * 只有大于 0 的值才覆盖该配置默认值。与 {@link RedisCacheEvict#ttl()} 的未设置 + * 编码一致。 */ - long ttl() default 60; + long ttl() default 0; /** * 缓存值的声明类型(兼容性元数据). diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCachePutOperation.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCachePutOperation.java index ad7af743..46f5a29c 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCachePutOperation.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCachePutOperation.java @@ -77,7 +77,8 @@ public static RedisCachePutOperation fromAttributes( @EqualsAndHashCode(callSuper = true) public static class Builder extends CachePutOperation.Builder implements RedisCacheAttributeSink { - private long ttl = 60; + /** 未设置哨兵 —— 与 {@link RedisCacheableOperation.Builder} 一致:0 表示无方法级 TTL 声明。 */ + private long ttl = 0; private Class type = Object.class; private boolean cacheNullValues; private boolean useBloomFilter; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java index 7605b52d..4a4c8f53 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicy.java @@ -8,39 +8,32 @@ /** * TTL 优先级的唯一实现 —— 把两个真实输入解析为 {@link TtlDecision}。 * - *

          输入与优先级(自上而下,第一条命中即胜出;与历史行为逐条一致): + *

          输入与优先级(自上而下,第一条命中即胜出): *

            *
          1. 注解:方法级 {@link CachePolicyView#ttl()} 秒数 > 0 时使用注解秒数, - * 并按 {@code randomTtl}/{@code variance} 抖动;
          2. + * 并按 {@code randomTtl}/{@code variance} 抖动。注解属性未设置时其值为 {@code 0}, + * 不构成声明 —— 注解是唯一能压过配置默认值的声明面; *
          3. 参数:{@link Duration} 非空、非零、非负时使用其秒数。写路径的这个 Duration * 由 Spring Data Redis 依 cache 级配置算出并传入({@code resi-cache.default-ttl}, - * 默认 30 分钟;{@code caches.*.ttl} 可覆盖),因此"配置的默认 TTL"只在方法级 - * TTL 为 0 或不存在时才生效;
          4. - *
          5. 参数为 {@code null}(无 TTL 上下文)时使用 {@link #DEFAULT_TTL_SECONDS};
          6. - *
          7. 参数为零或负 → 永久缓存({@link TtlDecision#skipped()})。
          8. + * 默认 30 分钟;{@code caches.*.ttl} 可覆盖),因此"配置的默认 TTL"是唯一的 + * 隐式默认值,只在方法级 TTL 未声明时才生效; + *
          9. 其余情况(参数为零、为负、或为 {@code null})→ 永久缓存 + * ({@link TtlDecision#skipped()})。与 Spring Data Redis 同义: + * {@code DefaultRedisCacheWriter.shouldExpireWithin} 把 null、零、负一样视为 + * "无过期",故三者不再各自表述。
          10. *
          * - *

          已知分歧(产品决策待定,只在此处陈述,勿在第二处重复):注解声明侧的 60 秒 - * ({@code @RedisCacheable}/{@code @RedisCachePut} 的 {@code ttl} 属性默认值,以及 Spring - * {@code @CachePut} 适配路径的 {@link RedisCachePutOperation} builder 默认值)会覆盖 cache 级 - * {@code resi-cache.default-ttl}(默认 30 分钟)。评审判定"60 秒还是 30 分钟应胜出"属于产品 - * 问题且尚无裁决,故两条默认值均按现状保留。 + *

          已裁决的规则(此处为唯一陈述处):注解 {@code ttl} 属性未设置时不再有 + * 隐式 60 秒默认值,该方法的条目落回 cache 级 {@code resi-cache.default-ttl} + * (默认 30 分钟)。配置默认值是唯一的隐式默认值;注解是唯一能覆盖它的声明。 */ final class TtlPolicy { - /** - * 注解与参数都不提供 TTL 时的兜底秒数。 - * - *

          与 {@code @RedisCacheable}/{@code @RedisCachePut} 的 {@code ttl} 属性默认值相等 —— - * 该相等关系使"属性未设置"与"无参数"两条路径得出同一结果,单方面改动任一侧即改变行为。 - */ - static final long DEFAULT_TTL_SECONDS = 60; - /** TTL 来源 —— 与写链的三条 debug 日志一一对应。 */ enum Source { /** 方法级注解策略({@link CachePolicyView#ttl()} > 0)。 */ ANNOTATION, - /** {@link Duration} 参数(含参数为 {@code null} 时的兜底秒数)。 */ + /** {@link Duration} 参数(cache 级 {@code resi-cache.default-ttl} 的 Spring 计算值)。 */ PARAMETER, /** 不应用 TTL(永久缓存)。 */ NONE @@ -64,7 +57,7 @@ private TtlPolicy() { /** * 解析 TTL。 * - * @param parameterTtl 调用方 TTL(Duration);可为 {@code null}(无 TTL 上下文)、零或负(永久语义) + * @param parameterTtl 调用方 TTL(Duration);{@code null} 与零、负同为"无过期"语义 * @param policy 方法级注解策略视图;{@link CachePolicyView#NONE} 表示无方法级声明 * @return 决策与来源 */ @@ -80,10 +73,6 @@ static Resolution resolve(Duration parameterTtl, CachePolicyView policy) { return new Resolution( TtlDecision.applied(parameterTtl.getSeconds()), Source.PARAMETER, false); } - if (parameterTtl == null) { - return new Resolution( - TtlDecision.applied(DEFAULT_TTL_SECONDS), Source.PARAMETER, false); - } return new Resolution(TtlDecision.skipped(), Source.NONE, false); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java index 31865d5e..d52ecfc1 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlHandlerTest.java @@ -180,14 +180,14 @@ void negativeParameterTtl_mapsToPermanentCacheSentinel() { } @Test - void missingTtl_usesDefaultTtl() { + void missingTtl_isPermanent() { CacheContext context = createContext(CacheOperation.PUT, null, annotatedOperation(0, false, 0.2f)); handler.doHandle(context, CacheResult::success); - assertThat(context.getTtlDecision().shouldApplyTtl()).isTrue(); - assertThat(context.getTtlDecision().finalTtl()).isEqualTo(60L); + assertThat(context.getTtlDecision().shouldApplyTtl()).isFalse(); + assertThat(context.getTtlDecision().finalTtl()).isEqualTo(-1L); } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java index 8f93d59d..c3e96a7a 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/TtlPolicyTest.java @@ -1,6 +1,7 @@ package io.github.davidhlp.spring.cache.redis.cache; import io.github.davidhlp.spring.cache.redis.annotation.RedisCacheable; +import io.github.davidhlp.spring.cache.redis.annotation.RedisCachePut; import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; import io.github.davidhlp.spring.cache.redis.chain.model.CachePolicyView; import io.github.davidhlp.spring.cache.redis.protection.refresh.EarlyExpirationMode; @@ -12,9 +13,10 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * TtlPolicy 单元测试 —— TTL 优先级(注解 / Duration 参数 / 兜底默认)与抖动,均不经 handler 链。 + * TtlPolicy 单元测试 —— TTL 优先级(注解 / Duration 参数 / 永久)与抖动,均不经 handler 链。 * - *

          行为基线:每条用例断言的是 TtlPolicy 引入前后逐字保留的既有结果。 + *

          行为基线:TtlHandler 之前的 5 份 TTL 编码收敛为 TtlPolicy 之后,每条用例断言 + * 解析出的 TTL;注解属性未设置的落回配置默认值是本次裁决的行为变更(deltas.md D2)。 */ @DisplayName("TtlPolicy Tests") class TtlPolicyTest { @@ -48,21 +50,23 @@ void annotationSet_winsOverParameter() { } @Test - @DisplayName("attribute unset (annotation default 60s) wins over the 30m parameter") - void annotationUnsetDefault_winsOverParameter() { - TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(60)); + @DisplayName("attribute unset (0) falls through to the 30m configured default") + void annotationUnset_fallsThroughToConfiguredDefault() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, annotationPolicy(0)); - assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); - assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(resolution.decision().shouldApplyTtl()).isTrue(); + assertThat(resolution.decision().finalTtl()).isEqualTo(1800L); } @Test - @DisplayName("attribute unset without any parameter still resolves to 60s") - void annotationUnsetDefault_withoutParameter_resolvesTo60s() { - TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, annotationPolicy(60)); + @DisplayName("attribute unset without any parameter is permanent") + void annotationUnset_withoutParameter_isPermanent() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, annotationPolicy(0)); - assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); - assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.NONE); + assertThat(resolution.decision().shouldApplyTtl()).isFalse(); + assertThat(resolution.decision().finalTtl()).isEqualTo(-1L); } @Test @@ -75,12 +79,12 @@ void annotationZero_usesParameter() { } @Test - @DisplayName("attribute explicitly 0 without a parameter falls back to 60s") - void annotationZero_withoutParameter_fallsBackTo60s() { - TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, annotationPolicy(0)); + @DisplayName("attribute unset with a zero parameter means permanent") + void annotationUnset_zeroParameter_isPermanent() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(Duration.ZERO, annotationPolicy(0)); - assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); - assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.NONE); + assertThat(resolution.decision().finalTtl()).isEqualTo(-1L); } @Test @@ -113,11 +117,21 @@ void noAnnotation_usesConfiguredDefault() { } @Test - @DisplayName("no method-level policy and no parameter falls back to 60s") - void noAnnotation_withoutParameter_fallsBackTo60s() { + @DisplayName("no method-level policy and no parameter means permanent") + void noAnnotation_withoutParameter_isPermanent() { TtlPolicy.Resolution resolution = TtlPolicy.resolve(null, CachePolicyView.NONE); - assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.NONE); + assertThat(resolution.decision().finalTtl()).isEqualTo(-1L); + } + + @Test + @DisplayName("no method-level policy with a zero parameter means permanent") + void noAnnotation_zeroParameter_isPermanent() { + TtlPolicy.Resolution resolution = TtlPolicy.resolve(Duration.ZERO, CachePolicyView.NONE); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.NONE); + assertThat(resolution.decision().finalTtl()).isEqualTo(-1L); } @Test @@ -145,24 +159,69 @@ private String annotatedWithDefaults(String id) { return id; } + @RedisCachePut(cacheNames = "ttl-policy-sample") + private String putWithDefaults(String id) { + return id; + } + + @RedisCacheable(cacheNames = "ttl-policy-sample", ttl = 45) + private String annotatedWithExplicitTtl(String id) { + return id; + } + @Test - @DisplayName("unset ttl attribute projects to 60s and wins over the 30m configured default") - void unsetTtlAttribute_resolvesTo60s() throws Exception { + @DisplayName("unset ttl attribute projects to 0 and resolves to the 30m configured default") + void unsetTtlAttribute_fallsThroughToTheConfiguredDefault() throws Exception { + CachePolicyView policy = projectedPolicy("annotatedWithDefaults"); + + assertThat(policy.ttl()).isZero(); + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, policy); + + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(resolution.decision().finalTtl()).isEqualTo(1800L); + } + + @Test + @DisplayName("unset ttl on @RedisCachePut also resolves to the configured default") + void unsetPutTtlAttribute_fallsThroughToTheConfiguredDefault() throws Exception { Method method = AnnotationDefaultLinkTests.class - .getDeclaredMethod("annotatedWithDefaults", String.class); - RedisCacheable annotation = method.getAnnotation(RedisCacheable.class); + .getDeclaredMethod("putWithDefaults", String.class); + RedisCachePut annotation = method.getAnnotation(RedisCachePut.class); RedisCacheAttributes attributes = new RedisCacheAttributesProjector().from(annotation); - RedisCacheableOperation operation = - RedisCacheableOperation.fromAttributes(method, annotation.key(), attributes); + RedisCachePutOperation operation = + RedisCachePutOperation.fromAttributes(method, annotation.key(), attributes); CachePolicyView policy = new CacheInput( - CacheOperation.PUT, "ttl-policy-sample", "k", "k", null, null, null, operation) - .policy(); + CacheOperation.PUT_IF_ABSENT, "ttl-policy-sample", "k", "k", null, null, null, + operation).policy(); + TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, policy); + + assertThat(policy.ttl()).isZero(); + assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.PARAMETER); + assertThat(resolution.decision().finalTtl()).isEqualTo(1800L); + } + + @Test + @DisplayName("an explicit ttl attribute still beats the configured default") + void explicitTtlAttribute_beatsTheConfiguredDefault() throws Exception { + CachePolicyView policy = projectedPolicy("annotatedWithExplicitTtl"); TtlPolicy.Resolution resolution = TtlPolicy.resolve(CONFIGURED_DEFAULT, policy); - assertThat(policy.ttl()).isEqualTo(60L); + assertThat(policy.ttl()).isEqualTo(45L); assertThat(resolution.source()).isEqualTo(TtlPolicy.Source.ANNOTATION); - assertThat(resolution.decision().finalTtl()).isEqualTo(60L); + assertThat(resolution.decision().finalTtl()).isEqualTo(45L); + } + + private static CachePolicyView projectedPolicy(String methodName) throws Exception { + Method method = + AnnotationDefaultLinkTests.class.getDeclaredMethod(methodName, String.class); + RedisCacheable annotation = method.getAnnotation(RedisCacheable.class); + RedisCacheAttributes attributes = new RedisCacheAttributesProjector().from(annotation); + RedisCacheableOperation operation = + RedisCacheableOperation.fromAttributes(method, annotation.key(), attributes); + return new CacheInput( + CacheOperation.PUT, "ttl-policy-sample", "k", "k", null, null, null, operation) + .policy(); } } From b64c60b0e87a9d2a11c094418f36afb8f04b28c3 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:20:57 +0800 Subject: [PATCH 40/56] docs(compatibility): record the ttl default resolution and its change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/REFERENCE.md` stated the annotation `ttl` default as 60 and that a null Duration parameter falls back to a 60-second default, and `COMPATIBILITY.md` recorded the 60s-versus-30m divergence as an unresolved product decision kept as-is. Both are now false. State the decided resolution in `REFERENCE.md` — annotation `ttl` greater than zero is the only declaration that overrides the configured default; an unset attribute falls through to `resi-cache.default-ttl`; a zero, negative or null parameter means no expiry. Record the behaviour change and its migration options in `COMPATIBILITY.md`, including the corrected claim that a caller-supplied `RedisCacheConfiguration` yields a zero (`persistent()`) parameter, not a null one. Co-Authored-By: Claude Code --- COMPATIBILITY.md | 26 +++++++++++++++++++++++++- docs/REFERENCE.md | 30 ++++++++++++++++++------------ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 982b81b1..c39efd6f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -109,7 +109,31 @@ not require a cache flush. `clear` deletion counts and PUT_IF_ABSENT insertion. `withStatisticsCollector` fully rebinds statistics; lock-wait duration remains unreported (zero). - **Class-level cache annotations**: Spring operation resolution sees class-level ResiCache annotations, but the annotation chain does not apply their policy fields to methods without method-level annotations; this behavior is unchanged from `main`. -- **TTL default precedence**: because the method-level `@RedisCacheable`/`@RedisCachePut` `ttl` attribute defaults to `60` seconds, annotating a method without an explicit `ttl` expires its entries after `60s` even when `resi-cache.default-ttl` (default `30m`) is configured; the configured default applies only where no method-level TTL is declared (`ttl=0`, or a plain Spring `@Cacheable` in `SELECTIVE` mode). Ordered resolution and its single owner (`TtlPolicy`) are specified in [`docs/REFERENCE.md`](docs/REFERENCE.md). Which default should win for an annotation without an explicit `ttl` is an unresolved product decision; both values are preserved as current supported behaviour and neither changes on this build line. +- **TTL default precedence**: one module owns the resolution (`TtlPolicy`; the + ordered rule is specified in [`docs/REFERENCE.md`](docs/REFERENCE.md)). A + method-level `ttl` greater than zero is the only declaration that overrides + the configured cache TTL. An annotated method that does not set `ttl` (the + attribute's value is then `0`, i.e. no declaration) expires its entries + after `resi-cache.default-ttl` (default `30m`, per-cache `caches.*.ttl` + overrides it), exactly like a plain Spring `@Cacheable` in `SELECTIVE` mode. + There is no second implicit TTL default: a zero or negative Duration + parameter means an entry without expiry, and a `null` parameter is treated + the same way — Spring Data Redis 4.0 expresses "no expiry" as + `Duration.ZERO` (`RedisCacheConfiguration`'s default `TtlFunction` is + `persistent()`, and `entryTtl` rejects `null`), so a write path never + carries a `null` TTL. +- **Annotation TTL fallback (behaviour change)**: on the previous build line + the `@RedisCacheable`/`@RedisCachePut` `ttl` attribute defaulted to `60` + seconds, so an annotated method without an explicit `ttl` expired its + entries after `60s` even when `resi-cache.default-ttl` was configured. The + annotation-side implicit `60` is removed: such a method now uses the + configured default (`30m` unless overridden), and `60` is no longer + reachable from the resolution path. Methods that set `ttl` explicitly are + unaffected. Deployments relying on the old `60s` expiry for methods that + omit `ttl` must either set `ttl` explicitly or set `resi-cache.default-ttl`. + A cache configured with no expiry (a caller-supplied + `RedisCacheConfiguration`) stays without expiry instead of receiving a + `60s` entry lifetime. - **Refresh metadata**: the version-2 envelope persists the fields required by early-expiration policy and version CAS (`ttl`, `createdTime`, access/visit counters, `expired`, and `version`). `startNanoTime` is process-local and is diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4fe847e3..35560be0 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -69,22 +69,28 @@ README snippet when the properties class or generated metadata differs. ### TTL resolution precedence Effective TTL resolves once, in package-private `TtlPolicy` (`cache/`; see -[`ARCHITECTURE.md`](ARCHITECTURE.md)), from three inputs — the first match wins: +[`ARCHITECTURE.md`](ARCHITECTURE.md)), from two inputs — the first match wins: 1. **Annotation**: method-level `@RedisCacheable`/`@RedisCachePut` `ttl` when - greater than zero (attribute default `60`), optionally jittered by - `randomTtl`/`variance`. + greater than zero, optionally jittered by `randomTtl`/`variance`. An unset + attribute is `0` and declares no method-level TTL; `@RedisCacheEvict.ttl()` + uses the same unset encoding. The annotation is the only declaration that + can override the configured default. 2. **Duration parameter**: the write-path TTL Spring Data Redis passes from the cache-level `resi-cache.default-ttl` (default `30m`, overridable per cache - under `caches.*.ttl`). It applies only when no method-level TTL is set — - `ttl=0`, or a plain Spring `@Cacheable` in `SELECTIVE` mode. A zero or - negative parameter yields a permanent entry (no expiry). -3. **No TTL context**: a `null` parameter (for example a caller-supplied - `RedisCacheConfiguration`) falls back to `TtlPolicy`'s `60`-second default. - -The consequence that branch 1's `60`-second annotation default overrides -`resi-cache.default-ttl` is recorded as a supported-behaviour limitation in -[`COMPATIBILITY.md`](../COMPATIBILITY.md). + under `caches.*.ttl`). This is the only implicit default, and it applies + whenever no method-level TTL is declared — an annotation without `ttl`, a + plain Spring `@Cacheable` in `SELECTIVE` mode, or `ttl=0`. +3. **Permanent entry**: a zero, negative, or `null` parameter applies no TTL + and the entry has no expiry. Spring Data Redis 4.0 has no separate `null` + case on a write path: `RedisCacheConfiguration`'s default `TtlFunction` is + `persistent()`, i.e. `Duration.ZERO`, and `entryTtl` rejects `null` — a + cache configured without expiry therefore produces a zero parameter. + +The `ttl` attribute no longer carries an implicit `60`-second default. An +annotated method that does not set `ttl` now expires its entries after the +configured default rather than after `60s`; the change and the migration +options are recorded in [`COMPATIBILITY.md`](../COMPATIBILITY.md). ## Cache operation outcomes From 3700c46a950689f15e117e5ad23911e609d9fc79 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:23:37 +0800 Subject: [PATCH 41/56] docs(changelog): roll up the c3 hook removal and the c8 ttl default --- CHANGELOG.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbc08c9..ec6618d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,8 +126,8 @@ Current milestones: `cacheNullValues` and early-expiration attributes (a writer that never filled the Bloom filter could leave a Bloom-enabled reader judging the key "definitely missing"). Write-only methods now honour their own declaration — - including `ttl()`, whose annotation default is 60 seconds, so such a method's - entries now expire after 60s where the cache-level TTL used to apply. A + including an explicitly set `ttl()`, which now overrides the cache-level TTL + on that path where it previously could not. A method that also declares `@RedisCacheable` keeps using the read-side declaration, because the read-through write-back is part of the read operation. @@ -264,8 +264,23 @@ Current milestones: methods, and the factory registers observers in the Spring-resolved injection order instead of re-sorting by an annotation only it could see; observers ordered through `Ordered`, a `@Bean`-method `@Order`, a meta-annotation or a - proxy keep the position Spring gave them. The documented hook protocol, - `beforeNode` and the scope-token types are unchanged. + proxy keep the position Spring gave them. The dispatch itself is typed at both + levels — the chain-level and node-level results reach the end hooks as + `CacheResult` / `HandlerResult` without a cast — and the chain observer + protocol no longer checks its own scope token's runtime type. +- ⚠️ **`ChainObserver.beforeNode` removed (c3)** — the hook had no production + implementer. An extension that overrode it must move that work to + `onNodeStart` / `afterNode`; every other hook keeps its name and semantics. + See `STABILITY.md` §4 for the migration note. +- ⚠️ **An annotated method without an explicit `ttl` now takes the configured TTL (c8)** — + `@RedisCacheable#ttl` and `@RedisCachePut#ttl` default to `0`, which `TtlPolicy` reads as + "no method-level declaration", so a method that does not set `ttl` falls through to + `resi-cache.default-ttl` (default 30 minutes) instead of the previous implicit 60 seconds. + An explicitly set positive `ttl` still wins and still applies its jitter. + `TtlPolicy.DEFAULT_TTL_SECONDS` and its `null`-`Duration` branch are gone, and a zero, + negative or `null` TTL all mean "no expiry", matching Spring Data Redis's own + `DefaultRedisCacheWriter.shouldExpireWithin`; a direct writer/SPI `put(…, null)` therefore + writes a persistent entry where it previously wrote a 60-second one. - **Handler identity declared once (c4)** — order slot, protection disable name, metric/log tag and the ordering requirement of each slot are declared alongside `HandlerOrder` and resolved by `cache/HandlerIdentity`; emitted tag From 163318d835d205090a26008e95d602b3fad1fee5 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:43:03 +0800 Subject: [PATCH 42/56] fix(cache): make the disabled metrics seam stateless and shared The disabled path was an empty `CompositeMeterRegistry`, which publishes nothing but still retains every meter id and tag string it is asked for in its own meter map. With dynamically named caches that map grows for the life of the context. Replace it with `DisabledMetricsRegistry`: a stateless `MeterRegistry` over Micrometer's public `io.micrometer.core.instrument.noop.*` types, whose constructor installs a deny-all `MeterFilter` so the base class short-circuits before its own `meterMap.put` and nothing is retained. Because it holds no state it can be a single JVM-wide instance, which is what `docs/REFERENCE.md` already claimed; the Javadoc and the reference now state the real sharing behaviour. Covered by `DisabledMetricsRegistryTest` (same instance across resolutions, meter table stays empty over repeated dynamic-name registrations) and the updated no-registry choice assertion in the configuration contract test. Co-Authored-By: Claude Code --- docs/REFERENCE.md | 7 +- .../redis/cache/DisabledMetricsRegistry.java | 106 ++++++++++++++++++ .../cache/redis/cache/ResolvedMetrics.java | 28 ++--- .../cache/DisabledMetricsRegistryTest.java | 44 ++++++++ ...edisProCacheConfigurationContractTest.java | 12 +- 5 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistryTest.java diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 35560be0..2bc8ff70 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -42,8 +42,11 @@ The main configuration groups are: metrics opt-in. It has no `RedisProCacheProperties` field: package-private `ResolvedMetrics` (`cache/`) reads it in exactly one place and hands every caller a non-null metrics seam — the application `MeterRegistry` when the - property is `true` and such a bean exists, otherwise a shared no-op adapter. - Its metadata comes from + property is `true` and such a bean exists, otherwise the single shared + `DisabledMetricsRegistry`: one stateless sink for the whole JVM that registers + and retains nothing, so turning metrics off cannot accumulate meter ids or tag + strings for dynamically named caches, and the same instance serves every + context that resolves it. Its metadata comes from `additional-spring-configuration-metadata.json`. Configuration is validated at binding time. Do not infer a default from an old diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java new file mode 100644 index 00000000..2c02173d --- /dev/null +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java @@ -0,0 +1,106 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.FunctionTimer; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Measurement; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; +import io.micrometer.core.instrument.distribution.pause.PauseDetector; +import io.micrometer.core.instrument.noop.NoopCounter; +import io.micrometer.core.instrument.noop.NoopDistributionSummary; +import io.micrometer.core.instrument.noop.NoopFunctionCounter; +import io.micrometer.core.instrument.noop.NoopFunctionTimer; +import io.micrometer.core.instrument.noop.NoopGauge; +import io.micrometer.core.instrument.noop.NoopMeter; +import io.micrometer.core.instrument.noop.NoopTimer; +import java.util.concurrent.TimeUnit; +import java.util.function.ToDoubleFunction; +import java.util.function.ToLongFunction; + +/** + * metrics 关闭时的 sink —— 无状态、不持有任何 meter,故可全 JVM 共享一个实例。 + * + *

          为什么不能直接复用 {@code CompositeMeterRegistry}:{@code MeterRegistry} 的注册 + * 路径({@code getOrCreateMeter})总会把 meter 放进自己的 {@code meterMap} / + * {@code preFilterIdToMeterMap};没有任何 child 的 composite 只是"不发布",它仍然 + * 记住每一个被问过的 meter id 与 tag 字符串。对动态命名的 cache 而言这张表会随 + * context 生命周期无限增长 —— 这正是 metrics 关闭时不该发生的事。 + * + *

          本类因此做两件事: + *

            + *
          1. 构造期装一个 deny-all {@link MeterFilter} —— 基类的 {@code accept()} 在 + * {@code meterMap.put} 之前短路,注册返回基类自带的 {@code Noop*} 实例, + * 不落任何 map(这也让八个 abstract 方法实际不可达,仅作为基类契约的 + * 完整实现而存在);
          2. + *
          3. 八个 abstract 成员全部返回 Micrometer 公开的 {@code io.micrometer.core.instrument.noop.*} + * 类型,万一基类的短路路径将来变化,也仍然不会分配或保留真实 meter。
          4. + *
          + * + *

          实例本身无状态(无 child registry、无 meter 表),所以 {@link #INSTANCE} 是被 + * {@link ResolvedMetrics} 共享的单例,而不是每次决议新建一个。 + */ +final class DisabledMetricsRegistry extends MeterRegistry { + + /** 全 JVM 共享的无状态 sink;{@link ResolvedMetrics} 关闭路径唯一取值。 */ + static final MeterRegistry INSTANCE = new DisabledMetricsRegistry(); + + private DisabledMetricsRegistry() { + super(Clock.SYSTEM); + config().meterFilter(MeterFilter.deny()); + } + + @Override + protected Gauge newGauge(Meter.Id id, T obj, ToDoubleFunction valueFunction) { + return new NoopGauge(id); + } + + @Override + protected Counter newCounter(Meter.Id id) { + return new NoopCounter(id); + } + + @Override + protected Timer newTimer(Meter.Id id, DistributionStatisticConfig distributionStatisticConfig, + PauseDetector pauseDetector) { + return new NoopTimer(id); + } + + @Override + protected DistributionSummary newDistributionSummary(Meter.Id id, + DistributionStatisticConfig distributionStatisticConfig, double scale) { + return new NoopDistributionSummary(id); + } + + @Override + protected Meter newMeter(Meter.Id id, Meter.Type type, Iterable measurements) { + return new NoopMeter(id); + } + + @Override + protected FunctionTimer newFunctionTimer(Meter.Id id, T obj, ToLongFunction countFunction, + ToDoubleFunction totalTimeFunction, TimeUnit totalTimeFunctionUnit) { + return new NoopFunctionTimer(id); + } + + @Override + protected FunctionCounter newFunctionCounter(Meter.Id id, T obj, ToDoubleFunction countFunction) { + return new NoopFunctionCounter(id); + } + + @Override + protected TimeUnit getBaseTimeUnit() { + return TimeUnit.MILLISECONDS; + } + + @Override + protected DistributionStatisticConfig defaultHistogramConfig() { + return DistributionStatisticConfig.DEFAULT; + } +} diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java index a17e239b..2e970550 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResolvedMetrics.java @@ -1,7 +1,6 @@ package io.github.davidhlp.spring.cache.redis.cache; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.composite.CompositeMeterRegistry; import org.springframework.beans.factory.ObjectProvider; import org.springframework.core.env.Environment; import org.springframework.lang.Nullable; @@ -11,9 +10,10 @@ * once, so assembly and tests cross the same check. * *

          Callers always get a non-null seam. When metrics are disabled, or the application has - * no {@code MeterRegistry} bean, the seam is a {@link CompositeMeterRegistry} that never - * gets a child registry, so every recorded sample lands nowhere. The disabled case is - * therefore not a {@code registry == null} re-check at each caller. + * no {@code MeterRegistry} bean, the seam is the single shared + * {@link DisabledMetricsRegistry#INSTANCE} — a stateless sink that registers nothing and + * retains nothing. The disabled case is therefore not a {@code registry == null} re-check + * at each caller, and it cannot accumulate meters either. * *

          The opt-in is read before the provider is touched: with metrics disabled the * application's registry beans are neither resolved nor instantiated, so an ambiguous @@ -37,20 +37,16 @@ static ResolvedMetrics resolve( } /** - * Nothing-publishing metrics seam, created per resolution. + * Nothing-publishing metrics seam: the one shared, stateless + * {@link DisabledMetricsRegistry#INSTANCE}. * - *

          Deliberately not a shared instance: a {@link CompositeMeterRegistry} keeps every - * meter it is asked for in its own map, so one static sink would accumulate the meter - * ids and tag strings of every application context — and of every dynamically named - * cache — for the lifetime of the JVM. One adapter per resolution keeps that bounded by - * the context that owns the seam. - * - *

          {@code ponytail}: meters registered into this adapter are still allocated until the - * owning context closes. A zero-allocation seam would need a hand-written - * non-registering {@code MeterRegistry}; add one only if a long-lived context is - * observed accumulating disabled-path meters. + *

          Sharing is safe because the sink holds no state — no child registries and no meter + * map — so every application context that turns metrics off (or that has no + * {@code MeterRegistry} bean) can use the same instance without leaking meter ids or + * tag strings between contexts, and without growing a map for the lifetime of a + * dynamically named cache. */ private static MeterRegistry noOpAdapter() { - return new CompositeMeterRegistry(); + return DisabledMetricsRegistry.INSTANCE; } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistryTest.java new file mode 100644 index 00000000..fd4ddbc0 --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistryTest.java @@ -0,0 +1,44 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * metrics 关闭路径的 seam 契约:共享单例、无状态、注册不留痕。 + * + *

          回归的是 R1:关闭路径曾经是"永远没有 child 的 {@code CompositeMeterRegistry}", + * 它不发布但仍然记住每一个被问过的 meter —— 动态命名的 cache 会让这张表活到 context 结束。 + */ +@DisplayName("Disabled metrics seam") +class DisabledMetricsRegistryTest { + + @Test + @DisplayName("关闭/无 registry:同一共享实例,反复注册后 meter 表仍为空") + void disabledSeam_isSharedAndRetainsNothing() { + assertThat(ResolvedMetrics.resolve(null, new MockEnvironment()).meterRegistry()) + .as("metrics 未开启") + .isSameAs(DisabledMetricsRegistry.INSTANCE) + .isSameAs(ResolvedMetrics.resolve(null, + new MockEnvironment().withProperty("resi-cache.metrics.enabled", "true")) + .meterRegistry()) + .isSameAs(ResolvedMetrics.resolve(null, new MockEnvironment()).meterRegistry()); + + MeterRegistry seam = DisabledMetricsRegistry.INSTANCE; + for (int i = 0; i < 64; i++) { + Counter counter = seam.counter("resicache.probe.counter", "cache", "dynamic-cache-" + i); + counter.increment(); + seam.timer("resicache.probe.timer", "cache", "dynamic-cache-" + i); + + assertThat(counter.count()).isZero(); + } + + assertThat(seam.getMeters()) + .as("无状态 sink:任何注册都不得进入 meter 表") + .isEmpty(); + assertThat(seam.find("resicache.probe.counter").counter()).isNull(); + } +} diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java index bd252716..b191d008 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheConfigurationContractTest.java @@ -11,7 +11,6 @@ import io.github.davidhlp.spring.cache.redis.protection.bloom.filter.BloomIFilter; import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.composite.CompositeMeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.lang.reflect.Method; import java.util.function.Consumer; @@ -25,6 +24,7 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.FilterType; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.mock.env.MockEnvironment; import static org.assertj.core.api.Assertions.assertThat; class RedisProCacheConfigurationContractTest { @@ -89,9 +89,13 @@ void metricsEnabled_withoutMeterRegistry_keepsNoOpChoice() throws Exception { assertThat(context).hasNotFailed(); assertThat(context).doesNotHaveBean(MeterRegistry.class); assertThat(context).hasSingleBean(ResolvedMetrics.class); - MeterRegistry seam = context.getBean(ResolvedMetrics.class).meterRegistry(); - assertThat(seam).isInstanceOf(CompositeMeterRegistry.class); - assertThat(((CompositeMeterRegistry) seam).getRegistries()).isEmpty(); + assertThat(context.getBean(ResolvedMetrics.class).meterRegistry()) + .isInstanceOf(DisabledMetricsRegistry.class) + .isSameAs(ResolvedMetrics.resolve(null, new MockEnvironment()) + .meterRegistry()); + assertThat(context.getBean(ResolvedMetrics.class).meterRegistry().getMeters()) + .as("无状态 sink:没有 bean 也不得留下 meter 痕迹") + .isEmpty(); }); } } From 043e9b3fea58821c93104b0225a703a22884a8fe Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:46:30 +0800 Subject: [PATCH 43/56] fix(cache): resolve value before cacheNames on both annotation faces `main` built the Spring operation with `ann.value().length > 0 ? ann.value() : ann.cacheNames()` at all three `parseRedisCache*` sites, so `value` won on the operation face. The c6 unification routes both faces through `RedisCacheAttributesProjector.resolveCacheNames`, which preferred `cacheNames` - a silent precedence flip for a declaration that sets both attributes (`@RedisCacheable(value="a", cacheNames="b")`). Both-set behaviour, stated explicitly: - on `main`: the operation targeted cache `a` (`value`), while the policy snapshot was registered under `b` (`cacheNames`), so the declared policy silently did not apply to the cache that was actually used; - now: both faces resolve to `a` (`value`). The cache in use is unchanged from `main`, and the policy now applies to it; the `cacheNames` alias no longer carries a second policy snapshot. Only the both-set declaration changes; declaring a single attribute - the overwhelmingly common case - is unaffected. The single resolution for both faces is kept (the c6 goal). The rule is recorded in COMPATIBILITY.md and in the three annotations' `value` / `cacheNames` Javadoc, and pinned by `AnnotationAopBehaviorMatrixTest.aliasResolutionIsSharedByBothFaces`, `AnnotationPolicySnapshotTest.bothCacheNameAttributesSet_valueWinsOnBothFaces` and the projector's `value_wins_over_cacheNames`. Co-Authored-By: Claude Code --- COMPATIBILITY.md | 11 ++++++ .../redis/annotation/RedisCacheEvict.java | 5 +++ .../cache/redis/annotation/RedisCachePut.java | 5 +++ .../redis/annotation/RedisCacheable.java | 5 +++ .../cache/RedisCacheAttributesProjector.java | 22 +++++++---- .../AnnotationAopBehaviorMatrixTest.java | 11 ++++-- .../cache/AnnotationPolicySnapshotTest.java | 37 +++++++++++++++++++ .../RedisCacheAttributesProjectorTest.java | 24 +++++++----- 8 files changed, 98 insertions(+), 22 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index c39efd6f..c9bc8594 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -109,6 +109,17 @@ not require a cache flush. `clear` deletion counts and PUT_IF_ABSENT insertion. `withStatisticsCollector` fully rebinds statistics; lock-wait duration remains unreported (zero). - **Class-level cache annotations**: Spring operation resolution sees class-level ResiCache annotations, but the annotation chain does not apply their policy fields to methods without method-level annotations; this behavior is unchanged from `main`. +- **`value` and `cacheNames` resolution**: the three annotations are not + `@AliasFor`-linked, so one declaration may set both attributes. There is one + resolution for both faces (`RedisCacheAttributesProjector.resolveCacheNames`) + and **`value` wins**; `cacheNames` is the fallback and only applies when + `value` is empty. A declaration that sets both targets the `value` cache. + This is the operation face's `main` behaviour, so the cache in use is + unchanged. The policy face changes for that same both-set declaration: on + `main` the operation targeted `value` while the policy snapshot was + registered under `cacheNames`, so the declared policy silently did not apply + to the cache that was used. Both faces now resolve to `value`, and the policy + applies to the cache in use. - **TTL default precedence**: one module owns the resolution (`TtlPolicy`; the ordered rule is specified in [`docs/REFERENCE.md`](docs/REFERENCE.md)). A method-level `ttl` greater than zero is the only declaration that overrides diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheEvict.java b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheEvict.java index b26e8b1b..106a9010 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheEvict.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheEvict.java @@ -21,11 +21,16 @@ /** * 缓存名称,与 Spring Cache 的 value 相同. + * + *

          同时声明 {@code value} 与 {@link #cacheNames()} 时 {@code value} 优先; + * 只声明其中一个时按声明的那个解析(见 {@code COMPATIBILITY.md} 的注解属性解析规则)。 */ String[] value() default {}; /** * 缓存名称别名,与 value 相同. + * + *

          同时声明 {@link #value()} 时由 {@code value} 决定——本属性只在其为空时生效。 */ String[] cacheNames() default {}; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java index 844bf95b..63f074df 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCachePut.java @@ -27,11 +27,16 @@ /** * 缓存名称,与 Spring Cache 的 value 相同. + * + *

          同时声明 {@code value} 与 {@link #cacheNames()} 时 {@code value} 优先; + * 只声明其中一个时按声明的那个解析(见 {@code COMPATIBILITY.md} 的注解属性解析规则)。 */ String[] value() default {}; /** * 缓存名称别名,与 value 相同. + * + *

          同时声明 {@link #value()} 时由 {@code value} 决定——本属性只在其为空时生效。 */ String[] cacheNames() default {}; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java index 67b39006..88bc63cf 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/annotation/RedisCacheable.java @@ -20,11 +20,16 @@ /** * 缓存名称,与 Spring Cache 的 value 相同. + * + *

          同时声明 {@code value} 与 {@link #cacheNames()} 时 {@code value} 优先; + * 只声明其中一个时按声明的那个解析(见 {@code COMPATIBILITY.md} 的注解属性解析规则)。 */ String[] value() default {}; /** * 缓存名称别名,与 value 相同. + * + *

          同时声明 {@link #value()} 时由 {@code value} 决定——本属性只在其为空时生效。 */ String[] cacheNames() default {}; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java index fafe37db..6091b89e 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjector.java @@ -60,7 +60,8 @@ class RedisCacheAttributesProjector { /** * 从 {@link RedisCacheable} 投影。 - *

          注:{@code cacheNames} 与 {@code value} 合并——{@code cacheNames} 优先、为空则用 {@code value}。 + *

          注:{@code value} 与 {@code cacheNames} 合并——同时声明两者时 {@code value} 优先 + * (见 {@link #resolveCacheNames})。 */ public RedisCacheAttributes from(RedisCacheable annotation) { return annotation == null ? null : project(extractFrom(annotation), false, false); @@ -271,14 +272,19 @@ private static FieldSource extractFrom(RedisCacheEvict annotation) { // --------------------------------------------------------------------- /** - * 解析缓存名称:{@code cacheNames} 优先,为空则用 {@code value}。 - * 这是原三个注解共有的语义——{@code value} 与 {@code cacheNames} 同义, - * Spring 的 {@code @Cacheable} 也遵循同一约定。 + * 解析缓存名称:{@code value} 优先,为空则用 {@code cacheNames}。 + * + *

          这是 {@code main} 上 AOP 面的既有语义(三个 {@code parseRedisCache*} 均写 + * {@code ann.value().length > 0 ? ann.value() : ann.cacheNames()}),c6 统一两面后本方法 + * 是唯一的解析点,因此 {@code value} 必须在两面上都赢——否则同时声明两者的注解会让 + * policy 面指向一个实际未被使用的 cache。 + * + *

          只声明其中一个时行为不变:另者为空数组,直接由非空的那一个决定。 */ - public static String[] resolveCacheNames(String[] cacheNames, String[] values) { - if (cacheNames != null && cacheNames.length > 0) { - return cacheNames; + public static String[] resolveCacheNames(String[] cacheNames, String[] value) { + if (value != null && value.length > 0) { + return value; } - return values != null ? values : new String[0]; + return cacheNames != null ? cacheNames : new String[0]; } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java index e2c6ba35..7345671d 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationAopBehaviorMatrixTest.java @@ -168,7 +168,7 @@ void bothFacesComeFromOneProjection() throws Exception { } @Test - @DisplayName("value/cacheNames alias resolution is shared by both faces") + @DisplayName("both-set value/cacheNames resolution is shared by both faces, value winning") void aliasResolutionIsSharedByBothFaces() throws Exception { Method method = method("aliased"); @@ -176,10 +176,13 @@ void aliasResolutionIsSharedByBothFaces() throws Exception { CacheableOperation aop = (CacheableOperation) operationSource.getCacheOperations(method, Matrix.class).iterator().next(); - assertThat(aop.getCacheNames()).containsExactly("alias-cache"); - assertThat(policy.getCacheNames()).containsExactly("alias-cache"); - assertThat(resolve("alias-cache", io.github.davidhlp.spring.cache.redis.chain.CacheOperation.GET)) + assertThat(aop.getCacheNames()).containsExactly("alias-value"); + assertThat(policy.getCacheNames()).containsExactly("alias-value"); + assertThat(resolve("alias-value", io.github.davidhlp.spring.cache.redis.chain.CacheOperation.GET)) .isSameAs(policy); + assertThat(resolve("alias-cache", io.github.davidhlp.spring.cache.redis.chain.CacheOperation.GET)) + .as("别名不承载 policy:两面对同一 cache,不得留下第二份快照") + .isNull(); } @Test diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationPolicySnapshotTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationPolicySnapshotTest.java index 46bc84b7..f4104fa8 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationPolicySnapshotTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AnnotationPolicySnapshotTest.java @@ -64,6 +64,35 @@ void operationSourceRegistrationIsConsumedByPolicyResolver() throws Exception { .isSameAs(snapshot.policyOperations().get(0)); } + /** + * {@code value} 与 {@code cacheNames} 同时声明时,两个面必须落到同一个 cache —— 且是 + * {@code main} 上 operation 面已经在用的那个({@code value})。否则 policy 会被注册到 + * 一个实际未被使用的 cache 上,静默失效。 + */ + @Test + @DisplayName("both attributes set: value wins on the operation face and the policy face") + void bothCacheNameAttributesSet_valueWinsOnBothFaces() throws Exception { + RedisCacheRegister register = new RedisCacheRegister(); + RedisCacheOperationSource source = new RedisCacheOperationSource( + RedisProCacheProperties.NativeAnnotationMode.SELECTIVE, register); + Method method = BothSetService.class.getMethod("read", String.class); + + java.util.Collection operations = + source.getCacheOperations(method, BothSetService.class); + AnnotationParser.ParsedAnnotations snapshot = + register.getSnapshot(method, BothSetService.class); + + assertThat(operations).singleElement().satisfies(operation -> + assertThat(operation.getCacheNames()).containsExactly("value-cache")); + assertThat(snapshot.operations().get(0).getCacheNames()).containsExactly("value-cache"); + assertThat(snapshot.policy(OperationKind.CACHEABLE, "value-cache")) + .as("policy 注册在实际使用的 cache 上") + .isSameAs(snapshot.policyOperations().get(0)); + assertThat(snapshot.policy(OperationKind.CACHEABLE, "names-cache")) + .as("别名不得再单独承载 policy") + .isNull(); + } + private static final class CountingAnnotationParser extends AnnotationParser { private final AtomicInteger invocations = new AtomicInteger(); @@ -85,4 +114,12 @@ public String read(String id) { return id; } } + + /** 注解未做 {@code @AliasFor} 关联,故两个属性可以同时声明且取值不同。 */ + static class BothSetService { + @RedisCacheable(value = "value-cache", cacheNames = "names-cache", key = "#id", ttl = 77) + public String read(String id) { + return id; + } + } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjectorTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjectorTest.java index c69afc89..239de1c3 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjectorTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheAttributesProjectorTest.java @@ -82,27 +82,27 @@ void explicitOverrides_arePassThrough() { } @Nested - @DisplayName("cacheNames vs value 合并") + @DisplayName("value / cacheNames 合并") class CacheNamesResolution { @Test - @DisplayName("cacheNames 非空优先使用") - void cacheNames_wins_over_value() { + @DisplayName("同时声明时 value 优先(与 main 的 operation 面一致)") + void value_wins_over_cacheNames() { RedisCacheable ann = stubCacheable(s -> { s.cacheNames = new String[]{"primary"}; s.values = new String[]{"fallback"}; }); - assertThat(projector.from(ann).getCacheNames()).containsExactly("primary"); + assertThat(projector.from(ann).getCacheNames()).containsExactly("fallback"); } @Test - @DisplayName("cacheNames 为空时回退到 value") - void value_used_when_cacheNames_empty() { + @DisplayName("value 为空时回退到 cacheNames") + void cacheNames_used_when_value_empty() { RedisCacheable ann = stubCacheable(s -> { - s.cacheNames = new String[0]; - s.values = new String[]{"fromValue"}; + s.cacheNames = new String[]{"fromCacheNames"}; + s.values = new String[0]; }); - assertThat(projector.from(ann).getCacheNames()).containsExactly("fromValue"); + assertThat(projector.from(ann).getCacheNames()).containsExactly("fromCacheNames"); } } @@ -148,7 +148,7 @@ void evictMissingFieldsFallBackSensibly() { class StaticUtils { @Test - @DisplayName("resolveCacheNames: 全部 null-safe") + @DisplayName("resolveCacheNames: 全部 null-safe,且同时声明时 value 优先") void resolveCacheNames_nullSafe() { assertThat(RedisCacheAttributesProjector.resolveCacheNames(null, null)) .isEmpty(); @@ -156,6 +156,10 @@ void resolveCacheNames_nullSafe() { .containsExactly("v"); assertThat(RedisCacheAttributesProjector.resolveCacheNames(new String[]{"c"}, null)) .containsExactly("c"); + assertThat(RedisCacheAttributesProjector.resolveCacheNames( + new String[]{"names-cache"}, new String[]{"value-cache"})) + .as("main 的 operation 面用 value;c6 统一后两面都必须是 value") + .containsExactly("value-cache"); } } From a256776d11808cb946ea1c292116369bcc95bc34 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:47:29 +0800 Subject: [PATCH 44/56] perf(cache): resolve handler identity once per handler class `CacheHandlerChain.handlerTag` is evaluated on the request hot path - per node per request by the Engine post-processing log and by `FiredCounterChainObserver.afterNode` - and the argument is evaluated even when DEBUG is off. Since c9 it is `HandlerIdentity.of(handler).tag()`, i.e. a reflective `getAnnotation(HandlerPriority.class)` plus a record allocation per call. Identity depends only on the handler class, so memoize the resolution in a `ClassValue` keyed by class: one reflective lookup per class for the life of the loader, and no static map holding strong class references across classloaders. Tag values are unchanged, including the class-simple-name fallback for handlers with no declared identity. `HandlerIdentityContractTest.identityIsResolvedOncePerHandlerClass` pins stable tags and same-instance resolution for both the annotated and the class-name-derived path. Co-Authored-By: Claude Code --- .../cache/redis/cache/HandlerIdentity.java | 18 ++++++++++ .../cache/HandlerIdentityContractTest.java | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java index a2217672..8641f9fe 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentity.java @@ -15,6 +15,12 @@ * 保留的类名派生路径,顺序值退到 {@link Integer#MAX_VALUE}(排在所有标准 slot 之后)。 * *

          标准 slot 的身份取值与跨 slot 的顺序要求由 {@code HandlerIdentityContractTest} 钉住。 + * + *

          按 handler 类解析一次:{@link #of(Class)} 的入参是 + * {@code CacheHandlerChain.handlerTag} 的专用路径,而后者在每个节点每次请求上被求值 + * (Engine 后置处理日志、{@code FiredCounterChainObserver.afterNode})。身份只由 handler 类 + * 决定,因此解析结果按类缓存({@link ClassValue},与类同生命周期,不产生跨类加载器的强引用 + * 表);一次反射的 {@code getAnnotation} 换一次缓存查找。 */ record HandlerIdentity( HandlerOrder slot, @@ -22,11 +28,23 @@ record HandlerIdentity( String disableName, String tag) { + /** handler 类 → 身份;取值恒定,故一次解析终身复用。 */ + private static final ClassValue CACHE = new ClassValue<>() { + @Override + protected HandlerIdentity computeValue(Class handlerClass) { + return resolve(handlerClass); + } + }; + static HandlerIdentity of(CacheHandler handler) { return of(handler.getClass()); } static HandlerIdentity of(Class handlerClass) { + return CACHE.get(handlerClass); + } + + private static HandlerIdentity resolve(Class handlerClass) { HandlerPriority priority = handlerClass.getAnnotation(HandlerPriority.class); if (priority != null) { HandlerOrder slot = priority.value(); diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java index 96bad0c1..743bce13 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/HandlerIdentityContractTest.java @@ -76,4 +76,40 @@ void actualCacheSlotIsLastSlot() { .as("新 slot 不得排在 ActualCache 之后") .isEqualTo(actualCache); } + + /** + * {@code handlerTag} 在每节点每次请求上求值(Engine 后置处理日志 / + * {@code FiredCounterChainObserver.afterNode}),故身份必须按类解析一次:返回同一个实例 + * 即证明第二次调用没有重跑反射 {@code getAnnotation}(也未分配新 record)。 + * 类名派生路径(无 {@code @HandlerPriority} 的宿主 handler)同样走缓存。 + */ + @Test + @DisplayName("身份按 handler 类解析一次:重复取值返回同一实例,类名回退路径亦然") + void identityIsResolvedOncePerHandlerClass() { + assertThat(HandlerIdentity.of(TtlHandler.class)) + .isSameAs(HandlerIdentity.of(TtlHandler.class)); + assertThat(HandlerIdentity.of(CustomTaglessHandler.class)) + .as("无注解的宿主 handler 走类名派生,同样只解析一次") + .isSameAs(HandlerIdentity.of(CustomTaglessHandler.class)); + assertThat(HandlerIdentity.of(CustomTaglessHandler.class).tag()) + .as("类名回退取值不变") + .isEqualTo("CustomTaglessHandler"); + + CacheHandler tagless = new CustomTaglessHandler(); + assertThat(CacheHandlerChain.handlerTag(tagless)) + .as("每请求求值,取值稳定") + .isEqualTo(CacheHandlerChain.handlerTag(new CustomTaglessHandler())) + .isEqualTo("CustomTaglessHandler"); + assertThat(HandlerIdentity.of(tagless.getClass())) + .isSameAs(HandlerIdentity.of(CustomTaglessHandler.class)); + } + + /** 无 {@code @HandlerPriority} 的宿主自定义 handler —— 类名派生路径的样本。 */ + static final class CustomTaglessHandler implements CacheHandler { + @Override + public io.github.davidhlp.spring.cache.redis.chain.HandlerResult handle( + io.github.davidhlp.spring.cache.redis.chain.model.CacheContext ctx) { + return io.github.davidhlp.spring.cache.redis.chain.HandlerResult.continueChain(); + } + } } From ad108f2717c5dd3a44f059f5bc940591bffbeeef Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:50:01 +0800 Subject: [PATCH 45/56] fix(cache): restore the null-return DEBUG line at the codec's decision Deleting `NullValueEncoder` folded the null decision into `CacheValueCodec.toValueBytes` and dropped the DEBUG line `main` emitted at that decision. Log wording is frozen, so losing it is an observable change; the codec has no cacheName/key context to log from. The decision now lives once, in `CacheValueCodec.isNullDecision`, and both byte production and the log use it. The two `toValueBytes` call sites (`ActualCacheHandler` cached-hit and PUT_IF_ABSENT-existing) each hold the context, so a private `encodeForReturn(value, context)` in `ActualCacheHandler` carries the line with `main`'s exact wording and level - the codec itself stays context-free. Scope note: the restored line fires on the codec's whole decision, i.e. also when the value in hand is `NullValue.INSTANCE`. `main`'s trigger was the narrower `value == null` (its encoder saw a non-null `NullValue` and logged nothing); both cases mean the same thing - the null sentinel is being returned - and the cached-null read path (`CachedValue` payload `null`) is the case `main` did log, so that observable line is restored byte-for-byte. `ActualCacheNullReturnLogTest` pins the line for a null payload and for `NullValue.INSTANCE`, and its absence for a normal value. Co-Authored-By: Claude Code --- .../cache/redis/cache/ActualCacheHandler.java | 20 +++- .../cache/redis/cache/CacheValueCodec.java | 15 ++- .../cache/ActualCacheNullReturnLogTest.java | 107 ++++++++++++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheNullReturnLogTest.java diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java index e1c32aa8..6c5e9e3d 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheHandler.java @@ -146,11 +146,27 @@ private CacheResult processCacheHit(CacheContext context, CachedValue cachedValu // 读路径默认不触发写操作,避免写放大。 // 如需 TTI(读取刷新 TTL),应使用 Spring Data Redis 的 RedisCacheConfiguration.enableTimeToIdle(), // 由 Redis 6.2+ 的 GETEX 命令实现,无需重写 value。 - byte[] result = valueCodec.toValueBytes(cachedValue.getValue()); + byte[] result = encodeForReturn(cachedValue.getValue(), context); return CacheResult.success(result); } + /** + * 返回路径的 value 字节 + null 决策的 DEBUG 行。 + * + *

          null 决策本身属于 {@link CacheValueCodec}(它产出占位字节),但 {@code cacheName} / + * {@code key} 上下文只存在于调用点,故 codec 保持无上下文,日志留在两个持上下文的调用点上; + * 判定条件用 codec 的 {@link CacheValueCodec#isNullDecision} 而非复制一份,两者不会漂移。 + * 该行是 main 上 {@code NullValueEncoder} 的原样恢复(同一措辞与级别)。 + */ + private byte[] encodeForReturn(Object value, CacheContext context) { + if (CacheValueCodec.isNullDecision(value)) { + log.debug("Returning null value in standard format: cacheName={}, key={}", + context.getCacheName(), context.getRedisKey()); + } + return valueCodec.toValueBytes(value); + } + /** * 判断是否为有效的缓存命中 */ @@ -221,7 +237,7 @@ private CacheResult handlePutIfAbsent(CacheContext context) { context.getCacheName(), context.getRedisKey()); CachedValue existingValue = (CachedValue) valueOperations.get(context.getRedisKey()); if (existingValue != null) { - byte[] result = valueCodec.toValueBytes(existingValue.getValue()); + byte[] result = encodeForReturn(existingValue.getValue(), context); return CacheResult.existing(result); } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java index 811456ed..1afd2424 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheValueCodec.java @@ -51,6 +51,19 @@ public Object fromValueBytes(@NonNull byte[] valueBytes) { } } + /** + * null 决策:{@code null} 与 {@link NullValue#INSTANCE} 都写出受限 Java 序列化的 null 占位字节。 + * + *

          决策只写在这里一次,字节产出({@link #toValueBytes})与调用点的观测日志 + * ({@code ActualCacheHandler} 的返回路径)共用本谓词,判定条件不会两处漂移。 + * + * @param chainValue chain-facing value + * @return 需要写出 null 占位字节时返回 {@code true} + */ + static boolean isNullDecision(@Nullable Object chainValue) { + return chainValue == null || chainValue instanceof NullValue; + } + /** * 将 chain-facing value 写回 writer seam 所需的 value 字节。 * @@ -63,7 +76,7 @@ public Object fromValueBytes(@NonNull byte[] valueBytes) { */ @NonNull public byte[] toValueBytes(@Nullable Object chainValue) { - if (chainValue == null || chainValue instanceof NullValue) { + if (isNullDecision(chainValue)) { return SecureNullValueDeserializer.serializeNullValue(); } try { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheNullReturnLogTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheNullReturnLogTest.java new file mode 100644 index 00000000..e9c46502 --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ActualCacheNullReturnLogTest.java @@ -0,0 +1,107 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.cache.support.NullValue; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * main 上 {@code NullValueEncoder} 的 null 决策 DEBUG 行恢复契约。 + * + *

          措辞与级别冻结:{@code "Returning null value in standard format: cacheName={}, key={}"}。 + * 决策谓词由 {@link CacheValueCodec#isNullDecision} 单一持有,本测试从 call site(持上下文) + * 断言实际输出,回归"重构后该行消失"的可观测变化。 + */ +@DisplayName("null-return DEBUG line") +class ActualCacheNullReturnLogTest { + + private static final String CACHE = "null-value-cache"; + private static final String REDIS_KEY = "null-value-cache:key"; + + @Test + @DisplayName("缓存命中但 payload 为 null:输出 main 的原措辞 DEBUG 行") + void cachedNullHit_emitsMainDebugLine() { + ActualCacheHandler handler = handlerReturning(CachedValue.forTest(null, 60, 1L, 1L, false)); + CacheContext context = getContext(); + + assertThat(capturedDebug(handler, context)) + .containsExactly("Returning null value in standard format: cacheName=" + + CACHE + ", key=" + REDIS_KEY); + } + + @Test + @DisplayName("NullValue.INSTANCE 命中:同一决策,同一行") + void nullValueInstanceHit_emitsSameLine() { + ActualCacheHandler handler = + handlerReturning(CachedValue.forTest(NullValue.INSTANCE, 60, 1L, 1L, false)); + + assertThat(capturedDebug(handler, getContext())) + .containsExactly("Returning null value in standard format: cacheName=" + + CACHE + ", key=" + REDIS_KEY); + } + + @Test + @DisplayName("普通值命中:不得输出该行") + void nonNullHit_doesNotEmitTheLine() { + ActualCacheHandler handler = handlerReturning(CachedValue.forTest("payload", 60, 1L, 1L, false)); + + assertThat(capturedDebug(handler, getContext())) + .as("写一条不该有的日志同样是可观测变化") + .noneMatch(message -> message.contains("Returning null value in standard format")); + } + + /** 走 GET 路径,捕获 {@link ActualCacheHandler} 的 DEBUG 输出。 */ + private List capturedDebug(ActualCacheHandler handler, CacheContext context) { + Logger logger = (Logger) LoggerFactory.getLogger(ActualCacheHandler.class); + Level previous = logger.getLevel(); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.setLevel(Level.DEBUG); + logger.addAppender(appender); + try { + handler.handle(context); + return appender.list.stream() + .filter(event -> event.getLevel() == Level.DEBUG) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.contains("Returning null value in standard format")) + .toList(); + } finally { + logger.detachAppender(appender); + logger.setLevel(previous); + } + } + + private ActualCacheHandler handlerReturning(CachedValue stored) { + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(valueOperations.get(REDIS_KEY)).thenReturn(stored); + return new ActualCacheHandler( + mock(RedisTemplate.class), + valueOperations, + new CacheValueCodec(new ObjectMapper()), + mock(RefreshCancellation.class), + mock(CacheErrorHandler.class)); + } + + private CacheContext getContext() { + return CacheContext.of(CacheInput.builder() + .operation(CacheOperation.GET) + .cacheName(CACHE) + .redisKey(REDIS_KEY) + .actualKey("key") + .build()); + } +} From f42f88d3ee4eb59947f45f7d367b6153c2676dcc Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:50:48 +0800 Subject: [PATCH 46/56] docs(operations): document the health indicator's per-probe Redis cost Removing the `@ConditionalOnProperty(resi-cache.metrics.enabled)` gate is already documented, but the load it adds is not: every application that has Actuator, Redis and ResiCache now assembles `RedisCacheHealthIndicator`, so each `/actuator/health` probe runs a synchronous `connection.ping()`. State the per-probe round trip, that it is per application instance, and that the previous behaviour was no probe traffic at all when metrics were off; point at the connection-pool/probe-interval sizing consequence. The Actuator row in COMPATIBILITY.md carries the same note. No code change. Co-Authored-By: Claude Code --- COMPATIBILITY.md | 5 ++++- docs/OPERATIONS.md | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index c9bc8594..86236322 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -59,7 +59,10 @@ baseline. `resi-cache.metrics.enabled=true` (default OFF) and a `MeterRegistry`; otherwise the resolved metrics seam is a no-op adapter. `RedisCacheHealthIndicator` requires Actuator and the `HealthIndicator` - class; it is not gated on the metrics property. | + class; it is not gated on the metrics property, so an application with + Actuator and Redis assembles it and each `/actuator/health` probe issues a + synchronous Redis `connection.ping()` round trip (see the probe-cost note in + [`docs/OPERATIONS.md`](docs/OPERATIONS.md)). | | **Caffeine** | Bundled | Used internally for the local hash cache and bloom-filter bitset; not exposed as a multi-level cache. | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 680ad20d..583316e9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -44,7 +44,20 @@ and the application's `MeterRegistry`, a decision resolved once during assembly. When either is missing the metrics seam is a no-op adapter and nothing is published. The Redis health indicator is not gated by that switch; it needs the optional Actuator dependency and reports Redis connectivity plus -protection degradation. Writer statistics and failure reporting are bounded by +protection degradation. + +Because that indicator is assembled whenever Actuator, Redis and ResiCache are +all present, **every `/actuator/health` probe costs one synchronous +`connection.ping()` Redis round trip**. An orchestrator or load balancer that +polls health frequently (a Kubernetes liveness/readiness probe on a short +period, for example) therefore adds that traffic to Redis for each probe, per +application instance. Previously the indicator was gated on +`resi-cache.metrics.enabled`, so applications that left metrics off had no +probe traffic at all. Size the health-check interval and any Redis connection +pool accordingly, and prefer a dedicated low-frequency probe over reusing the +health endpoint as a load-balancer check. + +Writer statistics and failure reporting are bounded by the contracts in `STABILITY.md` and `COMPATIBILITY.md`; pre-1.0 metric names and log wording are not a general compatibility promise. From 67f0417f7a40f832485e0c60f8a21ef225badea6 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 12:58:21 +0800 Subject: [PATCH 47/56] test(cache): restore thin per-site key-privacy coverage Replacing `FailureLogKeyPrivacyTest` (591 lines) with `FailureReportTest` (294 lines) kept the seam plus BloomSupport, RefreshRetryPolicy, ChainEngine (observer) and SyncSupport, but dropped the per-site guards: `FailureReport` makes the key *argument* safe, yet nothing stopped a call site from concatenating the raw key into the free-text argument, and no test failed if one did. Restore one thin guard per dropped site, each asserting that the raw key placed in that site's context does not appear in its captured WARN/ERROR text (including the throwable message chain, which is where a leak would land). Sites covered: `RedisBloomIFilter` (add/check/delete), `DistributedLockManager` (acquire timeout WARN, interrupted ERROR, release retry WARN, release-exhausted ERROR, interrupted-during-retry ERROR), `ChainEngine` post-processing (execution and predicate), `EarlyRefresh` async refresh, `SyncRoleLockExecutor` lock acquire and release, and `SerializationMigrationEngine` forward plus rollback rejected key - the last of which the pre-replacement suite did not cover either (byte[] keys, never guarded before). Sites already covered elsewhere by message assertions (`DistributedLockManagerIntegrationTest` interrupt, `SyncSupportTest`, `BloomFailureLogKeyPrivacyTest` check) are not duplicated. Class list, before -> after: - dropped: `FailureLogKeyPrivacyTest` (591 lines) - kept: `FailureReportTest` (294 lines, unchanged) - added: `FailureLogKeyPrivacyTest` (473 lines, 12 site guards) Verified non-vacuous by mutation: making the seam emit the raw key turns 6 of the 12 guards red. Co-Authored-By: Claude Code --- .../redis/cache/FailureLogKeyPrivacyTest.java | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java new file mode 100644 index 00000000..3f6192eb --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/FailureLogKeyPrivacyTest.java @@ -0,0 +1,473 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.davidhlp.spring.cache.redis.chain.CacheHandler; +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.CacheResult; +import io.github.davidhlp.spring.cache.redis.chain.HandlerResult; +import io.github.davidhlp.spring.cache.redis.chain.model.CacheContext; +import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; +import io.github.davidhlp.spring.cache.redis.protection.breakdown.LockManager; +import io.github.davidhlp.spring.cache.redis.serialization.migration.SerializationMigrationPhase; +import io.github.davidhlp.spring.cache.redis.serialization.migration.SerializationMigrationProperties; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.env.MockEnvironment; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 各失败站点的 key 隐私 thin 覆盖 —— 每个站点断言「一条 WARN/ERROR 里不出现被放进上下文的 + * raw key」。 + * + *

          规则本体(配对、指纹、类型链渲染)只由 {@code FailureReportTest} 在 seam 级测一次;本类 + * 不重复枚举 message 文本,只保证每个曾经泄露过 raw key 的站点保留一条会红的守卫 —— + * 站点把 raw key 拼进自由文本参数时,这里失败。 + * + *

          覆盖站点:{@code RedisBloomIFilter}(add/check/delete)、{@code DistributedLockManager} + * (获取超时 / 中断 / 释放重试与耗尽 / 重试期中断)、{@code ChainEngine} 后置处理(执行与判定)、 + * {@code EarlyRefresh} 异步刷新、{@code SyncRoleLockExecutor} 锁获取与释放、 + * {@code SerializationMigrationEngine} 前向与回滚。 + */ +@DisplayName("failure-log key privacy per site") +class FailureLogKeyPrivacyTest { + + /** 必须不出现在 WARN/ERROR 中的原始 key 哨兵。 */ + private static final String SECRET_KEY = "secret-customer-key-42"; + private static final String CACHE = "privacy-cache"; + /** {@code SyncRole.Leader} 的 logger 名(嵌套类在包外不可直接引用)。 */ + private static final String SYNC_ROLE_LEADER_LOGGER = SyncRole.class.getName() + "$Leader"; + + + @Test + @DisplayName("RedisBloomIFilter:add/check/delete 失败各一条 ERROR,均不含 raw key") + @SuppressWarnings("unchecked") + void redisBloomFilter_failures_omitRawKey() { + RedisTemplate redisTemplate = mock(RedisTemplate.class); + when(redisTemplate.executePipelined(any(RedisCallback.class))) + .thenThrow(new IllegalStateException("bloom redis boom for key " + SECRET_KEY)); + when(redisTemplate.delete(anyString())) + .thenThrow(new IllegalStateException("bloom delete boom for key " + SECRET_KEY)); + + try (Capture capture = new Capture(RedisBloomIFilter.class)) { + RedisBloomIFilter filter = new RedisBloomIFilter( + redisTemplate, new BloomFilterConfig("bf:", 4096, 3, 64), + DisabledMetricsRegistry.INSTANCE); + filter.init(); + + filter.add(CACHE, SECRET_KEY); + assertThat(filter.mightContain(CACHE, SECRET_KEY)) + .as("check 失败必须 fail-open") + .isTrue(); + filter.clear(CACHE); + + assertThat(capture.warnErrorText()) + .contains("Bloom filter add failed") + .contains("Bloom filter check failed") + .contains("Bloom filter delete failed") + .contains("cacheName=" + CACHE) + .doesNotContain(SECRET_KEY); + } + } + + + @Test + @DisplayName("DistributedLockManager:获取超时 WARN 不含 raw key,只含指纹") + void distributedLockManager_acquireTimeout_omitsRawKey() throws InterruptedException { + RedisProCacheProperties properties = new RedisProCacheProperties(); + RLock notAcquired = mock(RLock.class); + when(notAcquired.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(false); + + try (Capture capture = new Capture(DistributedLockManager.class)) { + assertThat(managerWithLock(properties, notAcquired).tryAcquire(SECRET_KEY, 1)).isEmpty(); + + assertThat(capture.warnErrorText()) + .contains("Failed to acquire distributed lock") + .contains("keyFingerprint=" + FailureReport.fingerprint(SECRET_KEY)) + .doesNotContain(SECRET_KEY) + .doesNotContain(properties.getSyncLock().getPrefix() + SECRET_KEY); + } + } + + @Test + @DisplayName("DistributedLockManager:等待被中断 ERROR 与异常消息不含 raw key") + void distributedLockManager_interrupted_omitsRawKey() throws InterruptedException { + RedisProCacheProperties properties = new RedisProCacheProperties(); + RLock interrupted = mock(RLock.class); + when(interrupted.tryLock(anyLong(), anyLong(), any(TimeUnit.class))) + .thenThrow(new InterruptedException("interrupted while holding " + SECRET_KEY)); + DistributedLockManager manager = managerWithLock(properties, interrupted); + + try (Capture capture = new Capture(DistributedLockManager.class)) { + try { + assertThatThrownBy(() -> manager.tryAcquire(SECRET_KEY, 1)) + .isInstanceOf(RuntimeException.class) + .hasMessageNotContaining(SECRET_KEY); + } finally { + Thread.interrupted(); + } + + assertThat(capture.warnErrorText()) + .contains("Interrupted while waiting for distributed lock") + .doesNotContain(SECRET_KEY); + } + } + + @Test + @DisplayName("DistributedLockManager:释放重试 WARN 与耗尽 ERROR 不含 raw key 与异常 message") + void distributedLockManager_releaseFailures_omitRawKey() throws InterruptedException { + try (Capture capture = new Capture(DistributedLockManager.class)) { + RLock neverUnlocks = heldLock(); + doThrow(new IllegalStateException("unlock failed for key " + SECRET_KEY)) + .when(neverUnlocks).unlock(); + + lockHandleOf(neverUnlocks).close(); + + assertThat(capture.warnErrorText()) + .contains("Failed to release distributed lock on attempt 1") + .contains("Failed to release distributed lock after ") + .doesNotContain(SECRET_KEY) + .doesNotContain("unlock failed for key"); + } + } + + @Test + @DisplayName("DistributedLockManager:重试等待期被中断 ERROR 不含 raw key") + void distributedLockManager_interruptedDuringRetry_omitsRawKey() throws InterruptedException { + try (Capture capture = new Capture(DistributedLockManager.class)) { + RLock neverUnlocks = heldLock(); + doThrow(new IllegalStateException("unlock failed for key " + SECRET_KEY)) + .when(neverUnlocks).unlock(); + LockManager.LockHandle handle = lockHandleOf(neverUnlocks); + + Thread.currentThread().interrupt(); + try { + handle.close(); + } finally { + Thread.interrupted(); + } + + assertThat(capture.warnErrorText()) + .contains("Interrupted while retrying lock release") + .doesNotContain(SECRET_KEY); + } + } + + private RLock heldLock() throws InterruptedException { + RLock lock = mock(RLock.class); + when(lock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).thenReturn(true); + when(lock.isHeldByCurrentThread()).thenReturn(true); + return lock; + } + + private LockManager.LockHandle lockHandleOf(RLock lock) throws InterruptedException { + return managerWithLock(new RedisProCacheProperties(), lock) + .tryAcquire(SECRET_KEY, 1).orElseThrow(); + } + + private DistributedLockManager managerWithLock(RedisProCacheProperties properties, RLock lock) { + String lockKey = new DistributedLockManager(mock(RedissonClient.class), properties) + .buildLockKey(SECRET_KEY); + RedissonClient client = mock(RedissonClient.class); + when(client.getLock(lockKey)).thenReturn(lock); + return new DistributedLockManager(client, properties); + } + + + @Test + @DisplayName("ChainEngine:后置处理执行失败 ERROR 带 cacheName 但不含 raw key") + void chainEngine_postProcessFailure_omitsRawKey() { + CacheHandler failing = new CacheHandler() { + @Override + public HandlerResult handle(CacheContext ctx) { + return HandlerResult.continueChain(); + } + + @Override + public boolean requiresPostProcess(CacheContext ctx) { + return true; + } + + @Override + public void afterChainExecution(CacheContext ctx, CacheResult result) { + throw new IllegalStateException("post-process boom for key " + SECRET_KEY); + } + }; + + try (Capture capture = new Capture(ChainEngine.class)) { + new ChainEngine().execute(List.of(failing), context()); + + assertThat(capture.warnErrorText()) + .contains("Post-processing failed for") + .contains(CACHE) + .doesNotContain(SECRET_KEY) + .doesNotContain("post-process boom"); + } + } + + @Test + @DisplayName("ChainEngine:后置处理判定失败也不泄露 raw key") + void chainEngine_postProcessPredicateFailure_omitsRawKey() { + CacheHandler failing = new CacheHandler() { + @Override + public HandlerResult handle(CacheContext ctx) { + return HandlerResult.continueChain(); + } + + @Override + public boolean requiresPostProcess(CacheContext ctx) { + throw new IllegalStateException("predicate boom for key " + SECRET_KEY); + } + }; + + try (Capture capture = new Capture(ChainEngine.class)) { + assertThat(new ChainEngine().execute(List.of(failing), context()).isSuccess()).isTrue(); + + assertThat(capture.warnErrorText()) + .contains(CACHE) + .doesNotContain(SECRET_KEY) + .doesNotContain("predicate boom"); + } + } + + private CacheContext context() { + return CacheContext.of(CacheInput.builder() + .operation(CacheOperation.GET) + .cacheName(CACHE) + .redisKey(SECRET_KEY) + .actualKey(SECRET_KEY) + .build()); + } + + + @Test + @DisplayName("EarlyRefresh:异步刷新失败 ERROR 带 cacheName 但不含 raw key") + @SuppressWarnings("unchecked") + void earlyRefresh_asyncRefreshFailure_omitsRawKey() { + ValueOperations valueOperations = mock(ValueOperations.class); + when(valueOperations.get(any())) + .thenThrow(new IllegalStateException("redis down for key " + SECRET_KEY)); + EarlyRefresh earlyRefresh = new EarlyRefresh( + Clock.systemUTC(), + mock(ThreadPoolEarlyExpirationExecutor.class), + mock(RedisTemplate.class), + valueOperations); + + try (Capture capture = new Capture(EarlyRefresh.class)) { + earlyRefresh.performAsyncRefresh(SECRET_KEY, CACHE, null); + + assertThat(capture.warnErrorText()) + .contains("Async early-expiration failed") + .contains(CACHE) + .doesNotContain(SECRET_KEY) + .doesNotContain("redis down for key"); + } + } + + + @Test + @DisplayName("SyncRoleLockExecutor:锁获取失败 WARN 与异常消息不含 raw key") + void syncRoleLockExecutor_acquireFailure_omitsRawKey() { + SyncSupport support = new SyncSupport( + new ArrayList<>(List.of(refusingLockManager())), new RedisProCacheProperties()); + + try (Capture capture = new Capture(SYNC_ROLE_LEADER_LOGGER)) { + assertThatThrownBy(() -> support.executeSync(SECRET_KEY, () -> "v", 5)) + .isInstanceOf(RuntimeException.class) + .hasMessageNotContaining(SECRET_KEY); + + assertThat(capture.warnErrorText()) + .contains("failed to acquire distributed lock") + .doesNotContain(SECRET_KEY); + } + } + + @Test + @DisplayName("SyncRoleLockExecutor:锁释放失败 ERROR 不含 raw key 与异常 message") + void syncRoleLockExecutor_releaseFailure_omitsRawKey() { + LockManager releasingFailure = new LockManager() { + @Override + public Optional tryAcquire(String key, long timeoutSeconds) { + return Optional.of(() -> { + throw new IllegalStateException("release failed for key " + SECRET_KEY); + }); + } + + @Override + public int getOrder() { + return 0; + } + }; + SyncSupport support = new SyncSupport(List.of(releasingFailure), new RedisProCacheProperties()); + + try (Capture capture = new Capture(SYNC_ROLE_LEADER_LOGGER)) { + assertThat(support.executeSync(SECRET_KEY, () -> "v", 5)).isEqualTo("v"); + + assertThat(capture.warnErrorText()) + .contains("Failed to release distributed lock") + .doesNotContain(SECRET_KEY) + .doesNotContain("release failed for key"); + } + } + + private LockManager refusingLockManager() { + return new LockManager() { + @Override + public Optional tryAcquire(String key, long timeoutSeconds) { + return Optional.empty(); + } + + @Override + public int getOrder() { + return 0; + } + }; + } + + + @Test + @DisplayName("SerializationMigrationEngine:前向 rejected key WARN 只含指纹") + void serializationMigration_forwardRejectedKey_omitsRawKey() { + try (Capture capture = new Capture(SerializationMigrationEngine.class)) { + assertThat(engineWithFailingKeyRead(SerializationMigrationPhase.CUTOVER).migrate().failed()) + .isEqualTo(1); + + assertThat(capture.warnErrorText()) + .contains("Serialization migration rejected key") + .contains("keyFingerprint=" + FailureReport.fingerprint(keyBytes())) + .doesNotContain(SECRET_KEY); + } + } + + @Test + @DisplayName("SerializationMigrationEngine:回滚 rejected key WARN 不含 raw key 与备份后缀拼接") + void serializationMigration_rollbackRejectedKey_omitsRawKey() { + try (Capture capture = new Capture(SerializationMigrationEngine.class)) { + assertThat(engineWithFailingKeyRead(SerializationMigrationPhase.ROLLBACK).migrate().failed()) + .isEqualTo(1); + + assertThat(capture.warnErrorText()) + .contains("Serialization rollback rejected key") + .doesNotContain(SECRET_KEY); + } + } + + /** + * 迁移引擎的 key 是 {@code byte[]},泄露形态是 raw key 或 raw key+后缀。让 + * {@code stringCommands().get(key)} 抛异常即命中两个站点各自的 catch。 + */ + private SerializationMigrationEngine engineWithFailingKeyRead(SerializationMigrationPhase phase) { + RedisProCacheProperties properties = new RedisProCacheProperties(); + SerializationMigrationProperties migration = properties.getSerializer().getMigration(); + migration.setPattern("privacy:*"); + migration.setBatchSize(20); + migration.setMaxKeys(20); + migration.setDryRun(false); + migration.setPhase(phase); + + @SuppressWarnings("unchecked") + Cursor cursor = mock(Cursor.class); + when(cursor.hasNext()).thenReturn(true, false); + when(cursor.next()).thenReturn(keyBytes()); + + org.springframework.data.redis.connection.RedisKeyCommands keyCommands = + mock(org.springframework.data.redis.connection.RedisKeyCommands.class); + // scan 有两个重载(ScanOptions / 更具体的 KeyScanOptions),须显式限定参数类型, + // 否则 when(...) 会绑到默认的 KeyScanOptions 重载上。 + when(keyCommands.scan(any(org.springframework.data.redis.core.ScanOptions.class))) + .thenReturn(cursor); + org.springframework.data.redis.connection.RedisStringCommands stringCommands = + mock(org.springframework.data.redis.connection.RedisStringCommands.class); + when(stringCommands.get(any())).thenThrow( + new IllegalStateException("legacy read failed for key " + SECRET_KEY)); + + RedisConnection connection = mock(RedisConnection.class); + when(connection.keyCommands()).thenReturn(keyCommands); + when(connection.stringCommands()).thenReturn(stringCommands); + + RedisConnectionFactory factory = mock(RedisConnectionFactory.class); + when(factory.getConnection()).thenReturn(connection); + return new SerializationMigrationEngine( + factory, new ObjectMapper(), properties, new SecureJacksonSerializerFactory(), + ResolvedMetrics.resolve(null, new MockEnvironment())); + } + + private byte[] keyBytes() { + return (CACHE + ":migrated").getBytes(StandardCharsets.UTF_8); + } + + /** + * 捕获某个 logger 的 WARN/ERROR 文本 —— 格式化消息 + throwable message 链(异常 message + * 可能内嵌 raw key,故一并断言)。 + */ + private static final class Capture implements AutoCloseable { + + private final Logger logger; + private final Level previousLevel; + private final ListAppender appender = new ListAppender<>(); + + Capture(Class owner) { + this((Logger) LoggerFactory.getLogger(owner)); + } + + Capture(String loggerName) { + this((Logger) LoggerFactory.getLogger(loggerName)); + } + + private Capture(Logger logger) { + this.logger = logger; + this.previousLevel = logger.getLevel(); + appender.start(); + logger.setLevel(Level.DEBUG); + logger.addAppender(appender); + } + + String warnErrorText() { + StringBuilder text = new StringBuilder(); + for (ILoggingEvent event : appender.list) { + if (!event.getLevel().isGreaterOrEqual(Level.WARN)) { + continue; + } + text.append(event.getFormattedMessage()).append('\n'); + for (IThrowableProxy proxy = event.getThrowableProxy(); + proxy != null; + proxy = proxy.getCause()) { + text.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n'); + } + } + return text.toString(); + } + + @Override + public void close() { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + } +} From ad96e678c1f483a67d80a65b9167616c3160eb33 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 14:09:27 +0800 Subject: [PATCH 48/56] fix(metrics): keep the disabled seam's timer and counter maps empty The guard removal in 9e4c2fdc made the no-op sink reach three writers that still built their keys and filled their own maps, so "metrics off" retained state again: one TimerKey per distinct cacheName, one fired counter per handler class, one failure counter per tag combo. Derive the flag once at construction from DisabledMetricsRegistry, the sink that owns the meaning of "disabled" (isDisabledSeam), and return before key construction, map write and Noop* allocation. No caller sees a null seam and no site re-reads resi-cache.metrics.enabled. Regression: ChainObserverTest.noopSeam_retainsNoTimers drives 100 distinct cacheNames through the production ResolvedMetrics disabled path; the two no-op seam tests now assert the maps stay empty instead of only "no throw". Co-Authored-By: Claude Code --- .../redis/cache/CacheFailureReporter.java | 12 +++++++ .../redis/cache/ChainTimerChainObserver.java | 14 +++++++-- .../redis/cache/DisabledMetricsRegistry.java | 5 +++ .../cache/FiredCounterChainObserver.java | 7 +++++ .../redis/cache/CacheFailureReporterTest.java | 5 ++- .../cache/redis/cache/ChainObserverTest.java | 31 ++++++++++++++++--- 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java index 346f2240..f2c57be1 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporter.java @@ -41,10 +41,13 @@ final class CacheFailureReporter { public static final String METRIC_NAME = "resicache.cache.failure"; private final MeterRegistry registry; + /** 关闭路径唯一判据 —— 构造期从 seam 推导一次。 */ + private final boolean disabled; private final ConcurrentMap counters = new ConcurrentHashMap<>(); public CacheFailureReporter(MeterRegistry registry) { this.registry = registry; + this.disabled = DisabledMetricsRegistry.isDisabledSeam(registry); } /** @@ -57,6 +60,10 @@ public CacheFailureReporter(MeterRegistry registry) { public void report(@org.springframework.lang.Nullable CacheOperation operation, @org.springframework.lang.Nullable FailureKind kind, @org.springframework.lang.Nullable ErrorStrategy strategy) { + if (disabled) { + // 关闭路径:跳过 FailureKey 构造、map 查找与 NoopCounter。 + return; + } FailureKey key = new FailureKey( operation == null ? "UNKNOWN" : operation.name(), kind == null ? "UNKNOWN" : kind.name(), @@ -75,6 +82,11 @@ private Counter register(FailureKey key) { .register(registry); } + /** 测试用:暴露当前已注册的 counter 数。 */ + int registeredCounterCount() { + return counters.size(); + } + private record FailureKey(String operation, String kind, String strategy) { } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java index 97318465..7b272462 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java @@ -30,7 +30,8 @@ * *

          线程安全:Timer map 支持并发注册;{@link TimerScope} 是单次节点调用的不可变 * token,不在 observer 内保存共享的 per-call 状态。registry 由 {@link ResolvedMetrics} - * 单一决议、永不为 null;metrics 未启用时它是 no-op seam,计时样本不落任何出口。 + * 单一决议、永不为 null;metrics 未启用时它是 no-op seam,关闭路径不注册、不分配、 + * 不保留任何 timer —— map 保持为空。 */ @Order(3) // 执行顺序单一真值源=类级 @Order,见 MDCStampChainObserver 注释 final class ChainTimerChainObserver implements ChainObserver { @@ -38,10 +39,13 @@ final class ChainTimerChainObserver implements ChainObserver { static final String METRIC_NAME = "resicache.chain.execute"; private final MeterRegistry registry; + /** 关闭路径唯一判据 —— 构造期从 seam 推导一次,热路径只分支 final 字段。 */ + private final boolean disabled; private final ConcurrentMap timers = new ConcurrentHashMap<>(); public ChainTimerChainObserver(MeterRegistry registry) { this.registry = registry; + this.disabled = DisabledMetricsRegistry.isDisabledSeam(registry); } @Override @@ -52,9 +56,10 @@ public Object onNodeStart(CacheHandler handler, CacheContext context) { @Override public void onNodeEnd(CacheHandler handler, CacheContext context, Object scopeToken, HandlerResult result) { - if (result == null || scopeToken == null) { + if (disabled || result == null || scopeToken == null) { // 故障节点没有 HandlerResult,不伪造 decision;token 为 null 仅当本人 // onNodeStart 抛异常(Engine 不产生 token),同样无样本可记录。 + // disabled:关闭路径不构造 TimerKey、不写 map、不分配 NoopTimer。 return; } // Engine 按 observer index 严格配对回传,故 token 必然是本人 onNodeStart 返回的 @@ -78,6 +83,11 @@ private Timer registerTimer(TimerKey key) { .register(registry); } + /** 测试用:暴露当前已注册的 timer 数。 */ + int registeredTimerCount() { + return timers.size(); + } + private record TimerScope(long startNanos) { } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java index 2c02173d..f9b5cfbb 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/DisabledMetricsRegistry.java @@ -51,6 +51,11 @@ final class DisabledMetricsRegistry extends MeterRegistry { /** 全 JVM 共享的无状态 sink;{@link ResolvedMetrics} 关闭路径唯一取值。 */ static final MeterRegistry INSTANCE = new DisabledMetricsRegistry(); + /** 该 registry 是否为关闭路径的 no-op seam —— 关闭路径唯一判据。 */ + static boolean isDisabledSeam(MeterRegistry registry) { + return registry == INSTANCE; + } + private DisabledMetricsRegistry() { super(Clock.SYSTEM); config().meterFilter(MeterFilter.deny()); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java index 1a2c6b36..f66a0aa0 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/FiredCounterChainObserver.java @@ -38,11 +38,14 @@ final class FiredCounterChainObserver implements ChainObserver { private final MeterRegistry registry; + /** 关闭路径唯一判据 —— 构造期从 seam 推导一次。 */ + private final boolean disabled; /** handler 类 → fired counter;同名同 tag 重复 register 幂等,故 map 仅按 type 持有。 */ private final ConcurrentMap, Counter> firedCounters = new ConcurrentHashMap<>(); public FiredCounterChainObserver(MeterRegistry registry) { this.registry = registry; + this.disabled = DisabledMetricsRegistry.isDisabledSeam(registry); } @Override @@ -57,6 +60,10 @@ public Object onChainStart(CacheContext context) { @Override public void afterNode(CacheHandler handler, CacheContext context, io.github.davidhlp.spring.cache.redis.chain.HandlerResult result) { + if (disabled) { + // 关闭路径:跳过 handlerTag / ClassValue 查找、Counter.builder、map 写入与 NoopCounter。 + return; + } String handlerTag = CacheHandlerChain.handlerTag(handler); Counter counter = firedCounters.computeIfAbsent(handler.getClass(), klass -> Counter.builder("resicache.handler.fired") diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java index eb0c9a2b..dbcc07e3 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/CacheFailureReporterTest.java @@ -83,11 +83,14 @@ void report_nullArgs_usesUnknownTag() { } @Test - @DisplayName("no-op seam → 不抛异常,且不向应用 registry 注册任何 meter") + @DisplayName("no-op seam → 不抛异常,不保留 counter,且不向应用 registry 注册任何 meter") void noopRegistry_noOp() { CacheFailureReporter noRegistry = new CacheFailureReporter( ResolvedMetrics.resolve(null, new MockEnvironment()).meterRegistry()); noRegistry.report(CacheOperation.PUT, FailureKind.REDIS, ErrorStrategy.FAIL_FAST); + noRegistry.report(CacheOperation.GET, FailureKind.REDIS, ErrorStrategy.GRACEFUL_DEGRADATION); + + assertThat(noRegistry.registeredCounterCount()).isZero(); assertThat(registry.getMeters()).isEmpty(); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index 64f53fc1..ad79997a 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -129,6 +129,26 @@ void noopSeam_noOp() { observer.onNodeEnd(handler, ctx, scopeToken, HandlerResult.continueChain()); } + /** + * 回归守卫:关闭路径必须"不保留",而不只是"不发布"。100 个不同 cacheName 若进入 + * timer map,就重现了 metrics 关闭时本不该发生的按 cache 名无界增长。 + */ + @Test + @DisplayName("disabled seam 下 100 个不同 cacheName 不保留任何 Timer") + void noopSeam_retainsNoTimers() { + ChainTimerChainObserver observer = new ChainTimerChainObserver(noOpSeam()); + CacheHandler namedHandler = new ContinueHandler(); + + for (int i = 0; i < 100; i++) { + CacheContext dynamicContext = context("dynamic-cache-" + i, "same-key"); + Object scopeToken = observer.onNodeStart(namedHandler, dynamicContext); + observer.onNodeEnd(namedHandler, dynamicContext, scopeToken, + HandlerResult.continueChain()); + } + + assertThat(observer.registeredTimerCount()).isZero(); + } + @Test @DisplayName("成功节点按 handler、decision、cacheName 记录一次 Timer") void successfulNode_recordsBoundedTags() { @@ -240,11 +260,14 @@ public HandlerResult handle(CacheContext context) { class FiredCounterTests { @Test - @DisplayName("no-op seam → afterNode 自增不落任何出口,不抛异常") + @DisplayName("no-op seam → afterNode 自增不落任何出口,不保留 counter,不抛异常") void nullRegistry_noOp() { - ChainObserver observer = new FiredCounterChainObserver(noOpSeam()); - observer.afterNode(handler, ctx, HandlerResult.continueChain()); - // 无异常即可 + FiredCounterChainObserver observer = new FiredCounterChainObserver(noOpSeam()); + for (int i = 0; i < 5; i++) { + observer.afterNode(handler, ctx, HandlerResult.continueChain()); + } + + assertThat(observer.registeredCounterCount()).isZero(); } @Test From 6a400f6374e5eafd6c22cf1d89354b713e1c7583 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 14:09:36 +0800 Subject: [PATCH 49/56] fix(health): warn once per context for the degraded protection state The un-gating in 9e4c2fdc made the indicator unconditionally assembled, so its existing WARN was re-emitted on every health probe. The degradation is construction-constant (LockManager list plus the local-only property), so one latched WARN per context carries the same information without a per-probe log volume that scales with the probe cadence. Level, wording and both protection.degraded detail keys are unchanged, and every probe still reports the degraded state in its response; the indicator is not re-gated on any property. Co-Authored-By: Claude Code --- .../cache/RedisCacheHealthIndicator.java | 9 +++- .../cache/RedisCacheHealthIndicatorTest.java | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java index e03a7a3a..2f3dbf9d 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.atomic.AtomicBoolean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.health.contributor.Health; @@ -33,6 +34,8 @@ class RedisCacheHealthIndicator implements HealthIndicator { private final RedisTemplate redisCacheTemplate; private final SyncSupport syncSupport; + /** 降级 WARN 每 context 至多一条 —— 状态本身仍每次响应都报告在 detail 中。 */ + private final AtomicBoolean degradationWarned = new AtomicBoolean(); public RedisCacheHealthIndicator(RedisTemplate redisCacheTemplate, ObjectProvider syncSupportProvider) { @@ -57,7 +60,11 @@ public Health health() { if (syncSupport != null && syncSupport.isDegraded()) { // sync=true 但无分布式锁后端 — 降级为 local-only(单 JVM 锁,跨实例不协调) // 状态仍是 UP(Redis 可用),但 detail 记录 protection.degraded - log.warn("protection.degraded=local-only: sync=true 但无分布式锁后端,降级为单 JVM synchronized"); + // 降级状态在构造期即固定(LockManager 列表 + local-only 属性),而 health 端点按探针 + // 节奏被反复调用 —— 每探针一条恒同 WARN 只是噪声;状态本身仍在每次响应 detail 中报告。 + if (degradationWarned.compareAndSet(false, true)) { + log.warn("protection.degraded=local-only: sync=true 但无分布式锁后端,降级为单 JVM synchronized"); + } builder = builder .withDetail("protection.degraded", "local-only") .withDetail("protection.degraded.reason", diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java index eeb9de95..34f77eb2 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java @@ -1,7 +1,13 @@ package io.github.davidhlp.spring.cache.redis.cache; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.health.contributor.Health; import org.springframework.boot.health.contributor.Status; @@ -36,6 +42,41 @@ void reportsConnectivityAndProtectionDegradation() { .containsKey("protection.degraded.reason"); } + @Test + @DisplayName("degraded state is reported on every probe but warns only once per context") + void degradedState_warnsOncePerContext() { + RedisTemplate template = mock(RedisTemplate.class); + SyncSupport syncSupport = mock(SyncSupport.class); + when(template.execute(any(RedisCallback.class))).thenReturn("PONG"); + when(syncSupport.isDegraded()).thenReturn(true); + + RedisCacheHealthIndicator indicator = new RedisCacheHealthIndicator( + template, provider(syncSupport)); + + Logger logger = (Logger) LoggerFactory.getLogger(RedisCacheHealthIndicator.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + Health first = indicator.health(); + Health second = indicator.health(); + + assertThat(appender.list) + .filteredOn(event -> event.getLevel() == Level.WARN) + .singleElement() + .satisfies(event -> assertThat(event.getFormattedMessage()) + .isEqualTo("protection.degraded=local-only: sync=true 但无分布式锁后端,降级为单 JVM synchronized")); + assertThat(List.of(first, second)).allSatisfy(health -> { + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()) + .containsEntry("protection.degraded", "local-only") + .containsKey("protection.degraded.reason"); + }); + } finally { + logger.detachAppender(appender); + } + } + @Test @DisplayName("reports unexpected ping response as down") void reportsUnexpectedPingResponseAsDown() { From 216470e697df0f33531444fa59cca8144e86bf55 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 14:09:44 +0800 Subject: [PATCH 50/56] docs(cache): describe the disabled seam instead of the retired null contract Four javadocs still asserted that a null MeterRegistry means metrics are off. ResolvedMetrics has returned the shared stateless DisabledMetricsRegistry.INSTANCE since the seam commit, so those claims were false and read as evidence that disabled-path retention was impossible. State the current contract instead: the seam is never null, metrics-off is signalled by the shared INSTANCE and can be queried with isDisabledSeam, and registrations on it publish and retain nothing. Comment text only. Co-Authored-By: Claude Code --- .../cache/redis/cache/AbstractCacheHandler.java | 12 ++++++++---- .../redis/cache/RedisProCacheMetricsRegistry.java | 6 ++++-- .../spring/cache/redis/cache/RefreshTaskMetrics.java | 12 ++++++++---- .../spring/cache/redis/cache/ResiCacheFeatures.java | 10 +++++++--- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java index 037facaf..51808862 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java @@ -81,12 +81,16 @@ public record CounterMetadata(String name, String description) { /** * 工厂建链阶段注入 MeterRegistry({@code ChainHandlerChainFactory} 在 - * {@code createChain} 中遍历进链 handler 时调用)。registry 非空时子类 - * override {@link #semanticCounter()} 声明自身语义 counter 元数据 + * {@code createChain} 中遍历进链 handler 时调用)。生产路径注入的 registry 永不为 null + * —— 指标未启用(或应用无 {@code MeterRegistry} bean)时它是共享无状态的 + * {@link DisabledMetricsRegistry#INSTANCE}(唯一判据 + * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),在其上的注册是 no-op + * 分配:不发布、不保留任何 meter。registry 非空时子类 override + * {@link #semanticCounter()} 声明自身语义 counter 元数据 * ({@link CounterMetadata}),基类从元数据构建并持有唯一 counter 字段。 * uniform fired counter 由 {@code FiredCounterChainObserver} 按进链 handler - * 类统一注册,不在本方法范围。registry 缺失或子类未声明元数据时本方法为 - * no-op。幂等:同名同 tag 重复 register 返回既有实例。 + * 类统一注册,不在本方法范围。registry 为 null(仅测试/防御路径)或子类未声明元数据时 + * 本方法为 no-op。幂等:同名同 tag 重复 register 返回既有实例。 */ public void attachMeterRegistry(MeterRegistry registry) { if (registry == null) { diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java index 603f710b..0a0f2996 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java @@ -35,8 +35,10 @@ *

        • {@link #metrics()} — 返回当前 cache 实例的不可变指标快照
        • *
        * - *

        null-safe 语义:{@link MeterRegistry} 为 null 时(即未启用指标),全部 7 个内部 - * 字段为 null,所有 record 方法走 no-op 路径。 + *

        no-op seam 语义:{@link MeterRegistry} 永不为 null —— 指标未启用(或应用无 + * {@code MeterRegistry} bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE}, + * 唯一判据是 {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}。在该 seam 上的 + * 7 个注册是 no-op 分配:不发布、不保留任何 meter,所有 record 方法走 no-op 路径。 * *

        线程安全:本类仅在 cache 构造期由单线程初始化;运行期 record 方法调 * {@link Timer#record} / {@link Counter#increment}(Micrometer 自身线程安全)。metrics() 仅读 diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java index 501d3f45..067203a2 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java @@ -18,9 +18,12 @@ * 提前过期任务的 Micrometer 指标注册与计数:从 {@code ThreadPoolEarlyExpirationExecutor} 抽出, * 将指标注册(3 个 Counter + 2 个 Gauge)与计数逻辑集中于单一协作类,无锁、线程安全。 * - *

        {@code meterRegistry} 为 {@code null} 时不注册任何指标,所有 record 方法为空操作, - * 支持测试与无指标场景。提取收益(locality):原本散落在执行器构造器与各方法中的 - * Counter/Gauge 注册及 null 判定,现收敛为一处,执行器只需调用 {@code recordXxx()}。 + *

        {@code meterRegistry} 永不为 {@code null}:指标未启用(或应用无 {@code MeterRegistry} + * bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE}(唯一判据 + * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),在其上的注册是 no-op 分配 + * ——不发布、不保留任何 meter,所有 record 方法为空操作。提取收益(locality):原本散落在 + * 执行器构造器与各方法中的 Counter/Gauge 注册及 null 判定,现收敛为一处,执行器只需调用 + * {@code recordXxx()}。 */ @Slf4j final class RefreshTaskMetrics { @@ -32,7 +35,8 @@ final class RefreshTaskMetrics { /** * 注册指标到给定 registry。 * - * @param meterRegistry Micrometer registry(null 则不注册,所有计数为空操作) + * @param meterRegistry Micrometer registry(永不为 null;关闭路径为共享 no-op seam, + * 其上的注册不发布、不保留) * @param inFlight 活跃任务映射(用于 {@code prerefresh.active} Gauge) * @param executorService 线程池(为 {@link ThreadPoolExecutor} 时注册 {@code prerefresh.queue.size} Gauge) */ diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java index b6b075b2..90ea20ce 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ResiCacheFeatures.java @@ -17,14 +17,18 @@ * 需同时改动多个构造器 + bean 装配 + 各自 Javadoc。本值对象让该契约只存在一处:消费方 * 询问本对象,而非各自记忆可空语义;新增特性只动本类一处。 * - *

        可空语义:只有 {@code meterRegistry} 为 {@code null} 表示指标禁用(no-op 降级); - * 其余字段是生产恒装配的协作对象,消费方构造期校验非 null(装配错误即抛,不静默降级)。 + *

        no-op seam 语义:只有 {@code meterRegistry} 承载「指标禁用」信息,但它永不为 + * {@code null} —— 指标未启用(或应用无 {@code MeterRegistry} bean)时它是共享无状态的 + * {@link DisabledMetricsRegistry#INSTANCE}(唯一判据 + * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),在其上的注册是 no-op + * 分配:不发布、不保留任何 meter;其余字段是生产恒装配的协作对象,消费方构造期校验非 null + * (装配错误即抛,不静默降级)。 */ @Value @Builder class ResiCacheFeatures { - /** 指标注册表 —— null 表示不采集 timer/counter(null-safe no-op). */ + /** 指标注册表 —— 永不为 null;关闭路径为共享 no-op seam(不采集 timer/counter). */ @Nullable MeterRegistry meterRegistry; From 11b0a754622095eb20ac56d09c5043cf4523016a Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 14:17:02 +0800 Subject: [PATCH 51/56] docs: retire the remaining null-registry claims and roll up the seam fixes The disabled-seam work replaced "registry == null" with a never-null shared no-op sink, but two files still documented the retired contract. Restate both truthfully: production never injects null, the disabled sink allocates no-op meters that publish and retain nothing, and the null branch survives only for the test/defensive path. Also roll up the two behaviour notes into CHANGELOG.md: the disabled seam keeps no meter, timer or per-cache entry, and the degraded-protection warning fires once per context instead of once per health probe. Co-Authored-By: Claude Code --- CHANGELOG.md | 13 +++++++++++++ .../cache/redis/cache/AbstractCacheHandler.java | 3 ++- .../redis/cache/RedisProCacheMetricsRegistry.java | 9 ++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6618d7..bcfa237f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,6 +259,19 @@ Current milestones: `RedisCacheHealthIndicator` now reports Redis connectivity and protection degradation regardless of `resi-cache.metrics.enabled`; previously the unrelated metrics switch could suppress the indicator. +- **The disabled metrics seam retains nothing (c2)** — with + `resi-cache.metrics.enabled` off (the default) the resolved seam is a shared + stateless registry, and the chain's timer observer, the fired-counter observer + and the failure reporter short-circuit on it. A disabled application therefore + allocates and keeps no meter, no timer and no per-cache entry: an earlier + revision of this change had moved that retention into the timer observer's own + per-cache-name map. Metric names, tag keys and tag values are unchanged. +- **The degraded-protection warning fires once per context (c2)** — + `RedisCacheHealthIndicator` emits `protection.degraded=local-only` on its first + degraded observation instead of on every `/actuator/health` probe, which matters + where a load balancer or orchestrator probes frequently and no distributed lock + backend is installed. Level and wording are unchanged, and the degradation is + still reported in every health response's details. - **Observer order owned by the observer class (c3)** — the four standard observers declare `@Order(1..4)` on the class instead of on their `@Bean` methods, and the factory registers observers in the Spring-resolved injection diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java index 51808862..fb715513 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java @@ -75,7 +75,8 @@ public record CounterMetadata(String name, String description) { /** * 语义 counter 字段 — 由 {@link #attachMeterRegistry} 在子类声明 - * {@link #semanticCounter()} 非 null 时从元数据注册;registry 缺失时为 null。 + * {@link #semanticCounter()} 非 null 时从元数据注册;传入 null registry + * (仅测试/防御路径)时为 null。 */ private Counter semanticCounter; diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java index 0a0f2996..6f2c4d20 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java @@ -89,8 +89,11 @@ final class RedisProCacheMetricsRegistry { /** * 构造期一次性注册 7 个 metric — 在 cache 构造期调用一次,运行期 record 路径直接复用。 * - *

        内部注册 helper 保证 {@code meterRegistry == null} 时所有字段保持 null。 - * @param meterRegistry Micrometer 注册表(可为 null → 全部 7 字段为 null) + *

        生产路径注入的 registry 永不为 null —— 指标未启用(或应用无 {@code MeterRegistry} + * bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE},在其上注册的 7 个 + * metric 是 no-op 分配:不发布、不保留任何 meter。{@code meterRegistry == null} 仅测试/ + * 防御路径可达,此时内部注册 helper 让全部字段保持 null。 + * @param meterRegistry Micrometer 注册表(生产路径永不为 null;为 null 时全部 7 字段为 null) * @param cacheName cache 标识,作为 {@code tags("cache", cacheName)} 写入每个 metric */ public RedisProCacheMetricsRegistry(@Nullable MeterRegistry meterRegistry, String cacheName) { @@ -188,7 +191,7 @@ public void recordClear(Runnable body) { /** * 当前 cache 实例的指标快照。 * - *

        Counter 字段为 null 时(registry 缺失)对应字段为 0L。 + *

        Counter 字段为 null 时(仅测试/防御路径传入 null registry)对应字段为 0L。 * * @return 不可变指标快照 */ From f63951427f70f1e2196044a61a29e554f456d33f Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 15:04:59 +0800 Subject: [PATCH 52/56] fix(metrics): skip per-key meter work in the disabled migration seam record(outcome) is the engine's only MeterRegistry touch and is reached 1-4 times per scanned key. On the shared disabled seam the deny-all filter stops retention but not the work: each call rebuilt the varargs tag array, constructed a Meter.Id, walked the registry filter under the meter map lock and allocated a NoopCounter that was discarded. Derive the seam identity once in the constructor and return before that work, byte-for-byte the pattern already used by ChainTimerChainObserver, FiredCounterChainObserver and CacheFailureReporter. The guard sits at the single choke point, so all eight call sites are covered without a per-site enabled check. Co-Authored-By: Claude Code --- .../cache/SerializationMigrationEngine.java | 7 ++ ...zationMigrationEngineDisabledSeamTest.java | 78 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngineDisabledSeamTest.java diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java index 1d46e1b8..f5a70d9f 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngine.java @@ -44,6 +44,8 @@ class SerializationMigrationEngine private final LegacyValueDecoder legacyDecoder; private final SerializationMigrationProperties migration; private final MeterRegistry meterRegistry; + /** 关闭路径唯一判据 —— 构造期从 seam 推导一次,热路径只分支 final 字段。 */ + private final boolean disabled; public SerializationMigrationEngine( RedisConnectionFactory connectionFactory, @@ -58,6 +60,7 @@ public SerializationMigrationEngine( objectMapper, serializer.getAllowedPackagePrefixes(), serializer.getTypeProperty()); this.migration = serializer.getMigration(); this.meterRegistry = resolvedMetrics.meterRegistry(); + this.disabled = DisabledMetricsRegistry.isDisabledSeam(this.meterRegistry); } /** @@ -288,6 +291,10 @@ private void validateSettings() { } private void record(String outcome) { + if (disabled) { + // 关闭路径:跳过 tag 数组、Meter.Id 构造与 deny-all filter 遍历(单 key 可达 4 次)。 + return; + } meterRegistry.counter(METRIC_NAME, "phase", migration.getPhase().name(), "outcome", outcome).increment(); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngineDisabledSeamTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngineDisabledSeamTest.java new file mode 100644 index 00000000..4a1dd9ca --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SerializationMigrationEngineDisabledSeamTest.java @@ -0,0 +1,78 @@ +package io.github.davidhlp.spring.cache.redis.cache; + + + + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.davidhlp.spring.cache.redis.config.RedisProCacheProperties; +import io.github.davidhlp.spring.cache.redis.serialization.migration.SerializationMigrationProperties; +import io.github.davidhlp.spring.cache.redis.serialization.migration.SerializationMigrationReport; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisKeyCommands; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.mock.env.MockEnvironment; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link SerializationMigrationEngine} 关闭路径(共享 no-op seam)的 per-key 契约。 + * + *

        关闭路径的 seam 是吞掉一切调用的共享单例,它没有可观测输出 —— 这正是 deny-all + * filter 只能"不保留"、不能"不分配"的原因。因此本测试是白盒探针:把 + * {@code migration.phase} 置为 {@code null},{@code record(outcome)} 只有在真正求值 + * {@code migration.getPhase().name()} 时才会 NPE。关闭路径上 record 若在构造期推导出 + * 的 disabled 信号处提前返回,则整个 migrate() 完成且不触碰 phase。 + * + *

        RED(11b0a754):sidecar 分支命中 {@code record("skipped")},求值 null phase → + * NPE 穿出 migrate()。修后 record 提前返回,migrate() 正常返回报告。 + */ +@DisplayName("SerializationMigrationEngine disabled metrics seam") +class SerializationMigrationEngineDisabledSeamTest { + + @Test + @DisplayName("关闭 seam:每个 key 的 record() 在构造 metric tag 之前返回") + void disabledSeam_sidecarKey_skipsMeterWork() { + RedisProCacheProperties properties = new RedisProCacheProperties(); + SerializationMigrationProperties migration = properties.getSerializer().getMigration(); + migration.setPattern("privacy:*"); + migration.setMaxKeys(10); + migration.setBatchSize(10); + migration.setDryRun(false); + // 关闭路径探针:phase 为 null 时,record() 里唯一的 getPhase().name() 会 NPE。 + migration.setPhase(null); + + @SuppressWarnings("unchecked") + Cursor cursor = mock(Cursor.class); + when(cursor.hasNext()).thenReturn(true, false); + when(cursor.next()).thenReturn(sidecarKeyBytes(migration)); + + RedisKeyCommands keyCommands = mock(RedisKeyCommands.class); + when(keyCommands.scan(any(ScanOptions.class))).thenReturn(cursor); + RedisConnection connection = mock(RedisConnection.class); + when(connection.keyCommands()).thenReturn(keyCommands); + RedisConnectionFactory factory = mock(RedisConnectionFactory.class); + when(factory.getConnection()).thenReturn(connection); + + SerializationMigrationEngine engine = new SerializationMigrationEngine( + factory, new ObjectMapper(), properties, new SecureJacksonSerializerFactory(), + ResolvedMetrics.resolve(null, new MockEnvironment())); + + SerializationMigrationReport report = engine.migrate(); + + assertThat(report.skippedSidecars()).isEqualTo(1); + assertThat(DisabledMetricsRegistry.INSTANCE.getMeters()) + .as("关闭 seam 不保留任何 meter") + .isEmpty(); + } + + private byte[] sidecarKeyBytes(SerializationMigrationProperties migration) { + return ("privacy:migrated" + migration.getShadowSuffix()).getBytes(StandardCharsets.UTF_8); + } +} From ad5a8c86240c9d7409f0896069637539993a2025 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 15:05:13 +0800 Subject: [PATCH 53/56] fix(metrics): stop allocating Noop meters for the disabled cache registry The class's null-registry short-circuits were dead code, because the seam is never null. Widening the two shared registration helpers to reject the disabled seam makes all seven fields null on that path, so every existing null guard goes live again with no new field and no per-call check. That removes two residues at once: seven Meter.Id builds, filter walks, Noop* allocations and meter-map-lock acquisitions per distinct cache name, and the per-operation cost of holding Noop* meters, which made every get, put, evict and clear pay two clock reads, a try/finally and a virtual no-op record through the guards that were meant to short-circuit it. The null-registry path keeps its behaviour: isDisabledSeam(null) is false, so a null argument still yields the same null fields. Co-Authored-By: Claude Code --- .../cache/RedisProCacheMetricsRegistry.java | 34 ++++++++++++++----- .../RedisProCacheMetricsRegistryTest.java | 32 +++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java index 6f2c4d20..648e80a2 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistry.java @@ -37,8 +37,9 @@ * *

        no-op seam 语义:{@link MeterRegistry} 永不为 null —— 指标未启用(或应用无 * {@code MeterRegistry} bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE}, - * 唯一判据是 {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}。在该 seam 上的 - * 7 个注册是 no-op 分配:不发布、不保留任何 meter,所有 record 方法走 no-op 路径。 + * 唯一判据是 {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}。在该 seam 上 + * 7 个注册全部短路为 null 字段:不构造 {@link io.micrometer.core.instrument.Meter.Id}、 + * 不走 deny-all filter、不分配 Noop* meter,所有 record 方法走类内既有的 null 短路。 * *

        线程安全:本类仅在 cache 构造期由单线程初始化;运行期 record 方法调 * {@link Timer#record} / {@link Counter#increment}(Micrometer 自身线程安全)。metrics() 仅读 @@ -90,10 +91,10 @@ final class RedisProCacheMetricsRegistry { * 构造期一次性注册 7 个 metric — 在 cache 构造期调用一次,运行期 record 路径直接复用。 * *

        生产路径注入的 registry 永不为 null —— 指标未启用(或应用无 {@code MeterRegistry} - * bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE},在其上注册的 7 个 - * metric 是 no-op 分配:不发布、不保留任何 meter。{@code meterRegistry == null} 仅测试/ - * 防御路径可达,此时内部注册 helper 让全部字段保持 null。 - * @param meterRegistry Micrometer 注册表(生产路径永不为 null;为 null 时全部 7 字段为 null) + * bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE},此时 7 个注册全部 + * 短路:字段保持 null,不构造 meter id、不走 deny-all filter、不分配 Noop* meter。 + * {@code meterRegistry == null} 是测试/防御路径,与关闭 seam 落到同一组 null 字段。 + * @param meterRegistry Micrometer 注册表(生产路径永不为 null;为 null 或关闭 seam 时全部 7 字段为 null) * @param cacheName cache 标识,作为 {@code tags("cache", cacheName)} 写入每个 metric */ public RedisProCacheMetricsRegistry(@Nullable MeterRegistry meterRegistry, String cacheName) { @@ -212,11 +213,28 @@ String cacheName() { return cacheName; } + /** + * 测试用:暴露 7 个注册字段中非 null 的个数。关闭 seam / null registry 下应为 0, + * 启用 registry 下应为 7。 + */ + int registeredMeterCount() { + Object[] fields = { + getTimer, putTimer, evictTimer, hitCounter, missCounter, putCounter, evictCounter + }; + int registered = 0; + for (Object field : fields) { + if (field != null) { + registered++; + } + } + return registered; + } + // ==================== 私有 helper ==================== private static Timer registerTimer(@Nullable MeterRegistry registry, String name, String description, String cacheName) { - if (registry == null) { + if (registry == null || DisabledMetricsRegistry.isDisabledSeam(registry)) { return null; } return Timer.builder(name) @@ -227,7 +245,7 @@ private static Timer registerTimer(@Nullable MeterRegistry registry, String name private static Counter registerCounter(@Nullable MeterRegistry registry, String name, String description, String cacheName) { - if (registry == null) { + if (registry == null || DisabledMetricsRegistry.isDisabledSeam(registry)) { return null; } return Counter.builder(name) diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java index ba49b530..0ec95fb2 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheMetricsRegistryTest.java @@ -105,6 +105,38 @@ void nonNullRegistry_registersAllMetricsWithTag() { assertThat(evictCounter).as("evictCounter registered").isNotNull(); } + @Test + @DisplayName("关闭 seam — 7 个 metric 全部不注册,record 走类内既有 null 短路") + void disabledSeam_registersNothing() { + RedisProCacheMetricsRegistry seamBacked = + new RedisProCacheMetricsRegistry(DisabledMetricsRegistry.INSTANCE, CACHE_NAME); + + assertThat(seamBacked.registeredMeterCount()) + .as("关闭 seam 上注册不分配任何 meter") + .isZero(); + + // record 方法仍可用 — 走类内既有 null 短路,不触碰 seam + seamBacked.recordGet(() -> "value"); + seamBacked.recordHit(); + seamBacked.recordMiss(); + seamBacked.recordPut(() -> { }); + seamBacked.recordEvict(() -> { }); + seamBacked.recordClear(() -> { }); + + CacheMetrics snapshot = seamBacked.metrics(); + assertThat(snapshot.hitCount()).isZero(); + assertThat(snapshot.missCount()).isZero(); + assertThat(snapshot.putCount()).isZero(); + assertThat(snapshot.evictCount()).isZero(); + assertThat(DisabledMetricsRegistry.INSTANCE.getMeters()).isEmpty(); + } + + @Test + @DisplayName("启用 registry — 7 个 metric 全部注册(关闭 seam 不得改变启用路径)") + void enabledRegistry_registersAllSeven() { + assertThat(registry.registeredMeterCount()).isEqualTo(7); + } + @Test @DisplayName("每个 metric 携带 description(用于 Micrometer exposition)") void metricsHaveDescriptions() { From 62100b0c4c162f67dbf02dfb0e584bc7f2e61259 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 15:05:27 +0800 Subject: [PATCH 54/56] fix(metrics): return disabled-seam consumers to their null registration path Three more consumers registered through the shared disabled sink and then kept non-null Noop* fields, which defeated the null short-circuit they already had. Widen the single hook each class owns so the disabled seam falls into the null path the class already handles: the handler base's semantic counter (read inside the per-node chain loop), the refresh-task metrics' three counters (read on every submit, completion and cancellation) and the bloom filter's two failure counters (read on the failure path). The null arm stays, so the null-registry path the tests exercise directly is byte-identical. The two small package-private observers exist only so the cold sites can be asserted on; the disabled path has no other observable output, since the seam returns non-null no-op meters and retains nothing. Co-Authored-By: Claude Code --- .../redis/cache/AbstractCacheHandler.java | 11 +++---- .../cache/redis/cache/RedisBloomIFilter.java | 17 ++++++++++- .../cache/redis/cache/RefreshTaskMetrics.java | 28 ++++++++++++++---- ...stractCacheHandlerSemanticCounterTest.java | 22 ++++++++++++++ .../RedisBloomIFilterIntegrationTest.java | 28 ++++++++++++++++++ .../redis/cache/RefreshTaskMetricsTest.java | 29 +++++++++++++++++++ 6 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java index fb715513..166aa011 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandler.java @@ -85,16 +85,17 @@ public record CounterMetadata(String name, String description) { * {@code createChain} 中遍历进链 handler 时调用)。生产路径注入的 registry 永不为 null * —— 指标未启用(或应用无 {@code MeterRegistry} bean)时它是共享无状态的 * {@link DisabledMetricsRegistry#INSTANCE}(唯一判据 - * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),在其上的注册是 no-op - * 分配:不发布、不保留任何 meter。registry 非空时子类 override + * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),此时本方法直接返回: + * 不构造 counter、不走 deny-all filter、不分配 Noop* meter,{@code semanticCounter} + * 保持 null。registry 非空且非关闭 seam 时子类 override * {@link #semanticCounter()} 声明自身语义 counter 元数据 * ({@link CounterMetadata}),基类从元数据构建并持有唯一 counter 字段。 * uniform fired counter 由 {@code FiredCounterChainObserver} 按进链 handler - * 类统一注册,不在本方法范围。registry 为 null(仅测试/防御路径)或子类未声明元数据时 - * 本方法为 no-op。幂等:同名同 tag 重复 register 返回既有实例。 + * 类统一注册,不在本方法范围。registry 为 null(仅测试/防御路径)、为关闭 seam 或 + * 子类未声明元数据时本方法为 no-op。幂等:同名同 tag 重复 register 返回既有实例。 */ public void attachMeterRegistry(MeterRegistry registry) { - if (registry == null) { + if (registry == null || DisabledMetricsRegistry.isDisabledSeam(registry)) { return; } CounterMetadata metadata = semanticCounter(); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java index a3184461..3dbc9b84 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilter.java @@ -50,7 +50,7 @@ public void init() { this.hashPositionCache = Caffeine.newBuilder() .maximumSize(config.getHashCacheSize()) .build(); - if (meterRegistry != null) { + if (meterRegistry != null && !DisabledMetricsRegistry.isDisabledSeam(meterRegistry)) { this.checkFailureCounter = Counter.builder("bloomsift.check.failures") .description("Number of bloom filter check failures") .register(meterRegistry); @@ -162,4 +162,19 @@ public void clear(String cacheName) { private String bloomKey(String cacheName) { return config.getKeyPrefix() + cacheName; } + + /** + * 测试用:暴露 2 个已注册 failure counter 的个数。关闭 seam / null registry 下应为 0, + * 启用 registry 下应为 2。 + */ + int registeredFailureCounterCount() { + int registered = 0; + if (checkFailureCounter != null) { + registered++; + } + if (addFailureCounter != null) { + registered++; + } + return registered; + } } diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java index 067203a2..e8360a87 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetrics.java @@ -20,10 +20,10 @@ * *

        {@code meterRegistry} 永不为 {@code null}:指标未启用(或应用无 {@code MeterRegistry} * bean)时它是共享无状态的 {@link DisabledMetricsRegistry#INSTANCE}(唯一判据 - * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),在其上的注册是 no-op 分配 - * ——不发布、不保留任何 meter,所有 record 方法为空操作。提取收益(locality):原本散落在 - * 执行器构造器与各方法中的 Counter/Gauge 注册及 null 判定,现收敛为一处,执行器只需调用 - * {@code recordXxx()}。 + * {@link DisabledMetricsRegistry#isDisabledSeam(MeterRegistry)}),此时构造期直接走 null + * 分支——不构造 meter、不走 deny-all filter、不分配 Noop* counter/gauge,3 个 counter 字段 + * 保持 null,所有 record 方法为空操作。提取收益(locality):原本散落在执行器构造器与各方法中 + * 的 Counter/Gauge 注册及 null 判定,现收敛为一处,执行器只需调用 {@code recordXxx()}。 */ @Slf4j final class RefreshTaskMetrics { @@ -44,7 +44,7 @@ public RefreshTaskMetrics( MeterRegistry meterRegistry, ConcurrentHashMap> inFlight, ExecutorService executorService) { - if (meterRegistry == null) { + if (meterRegistry == null || DisabledMetricsRegistry.isDisabledSeam(meterRegistry)) { this.submittedCounter = null; this.completedCounter = null; this.cancelledCounter = null; @@ -94,4 +94,22 @@ public void recordCancelled() { cancelledCounter.increment(); } } + + /** + * 测试用:暴露 3 个已注册 counter 的个数。关闭 seam / null registry 下应为 0, + * 启用 registry 下应为 3。 + */ + int registeredCounterCount() { + int registered = 0; + if (submittedCounter != null) { + registered++; + } + if (completedCounter != null) { + registered++; + } + if (cancelledCounter != null) { + registered++; + } + return registered; + } } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandlerSemanticCounterTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandlerSemanticCounterTest.java index 5bc14b06..f9b963ee 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandlerSemanticCounterTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/AbstractCacheHandlerSemanticCounterTest.java @@ -123,6 +123,28 @@ void withOverride_counterRegistered() { assertThat(counter.getId().getDescription()).isEqualTo(WithSemanticHandler.COUNTER_DESC); } + @Test + @DisplayName("关闭 seam → 语义 counter 不注册(counter 字段保持 null)") + void disabledSeam_registersNoCounter() { + WithSemanticHandler h = new WithSemanticHandler(); + h.attachMeterRegistry(DisabledMetricsRegistry.INSTANCE); + + assertThat(h.getSemanticCounter()) + .as("关闭 seam 上注册不绑定任何 counter") + .isNull(); + assertThat(DisabledMetricsRegistry.INSTANCE.getMeters()).isEmpty(); + h.incrementForTest(); // 沿类内既有 null 短路,不得抛 NPE + } + + @Test + @DisplayName("启用 registry → 语义 counter 绑定到基类字段") + void enabledRegistry_bindsCounterField() { + WithSemanticHandler h = new WithSemanticHandler(); + h.attachMeterRegistry(new SimpleMeterRegistry()); + + assertThat(h.getSemanticCounter()).isNotNull(); + } + @Test @DisplayName("重复 attach 同一 registry → 幂等(同名 register 返同一实例)") void idempotent_attachSameRegistry() { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilterIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilterIntegrationTest.java index 4c1422c9..49f3bbc4 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilterIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisBloomIFilterIntegrationTest.java @@ -3,6 +3,7 @@ +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -178,6 +179,33 @@ void clear_exception_doesNotThrow() { } } + @Nested + @DisplayName("metrics seam") + class MetricsSeamTests { + + @Test + @DisplayName("关闭 seam — 两个 failure counter 都不注册(字段保持 null)") + void disabledSeam_registersNoFailureCounters() { + RedisBloomIFilter seamFilter = + new RedisBloomIFilter(redisTemplate, config, DisabledMetricsRegistry.INSTANCE); + seamFilter.init(); + + assertThat(seamFilter.registeredFailureCounterCount()) + .as("关闭 seam 上注册不分配任何 counter") + .isZero(); + } + + @Test + @DisplayName("启用 registry — 两个 failure counter 注册") + void enabledRegistry_registersFailureCounters() { + RedisBloomIFilter meteredFilter = + new RedisBloomIFilter(redisTemplate, config, new SimpleMeterRegistry()); + meteredFilter.init(); + + assertThat(meteredFilter.registeredFailureCounterCount()).isEqualTo(2); + } + } + @Nested @DisplayName("False Positive Scenario") class FalsePositiveTests { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetricsTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetricsTest.java index 69c6075d..fe5f566b 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetricsTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RefreshTaskMetricsTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.Executors; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; /** @@ -77,6 +78,34 @@ void recordCancelled_incrementsCounter() { registry.counter("prerefresh.cancelled").count()).isEqualTo(1.0); } + @Test + @DisplayName("关闭 seam — 3 个 counter 都不注册(字段保持 null,record 走 null 短路)") + void disabledSeam_registersNoCounters() { + RefreshTaskMetrics metrics = new RefreshTaskMetrics( + DisabledMetricsRegistry.INSTANCE, + new ConcurrentHashMap<>(), + Executors.newSingleThreadExecutor()); + + assertThat(metrics.registeredCounterCount()) + .as("关闭 seam 上注册不分配任何 counter") + .isZero(); + assertThatCode(() -> { + metrics.recordSubmitted(); + metrics.recordCompleted(); + metrics.recordCancelled(); + }).doesNotThrowAnyException(); + } + + @Test + @DisplayName("启用 registry — 3 个 counter 全部注册") + void enabledRegistry_registersAllCounters() { + RefreshTaskMetrics metrics = new RefreshTaskMetrics( + new SimpleMeterRegistry(), new ConcurrentHashMap<>(), + Executors.newSingleThreadExecutor()); + + assertThat(metrics.registeredCounterCount()).isEqualTo(3); + } + @Test @DisplayName("ThreadPoolExecutor executor registers prerefresh.queue.size gauge") void constructor_threadPoolExecutor_registersQueueGauge() { From d3307297160f19be97ea5387d92443bcc3df02b0 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 15:25:26 +0800 Subject: [PATCH 55/56] fix(metrics): stop allocating a timer scope token on the disabled seam The disabled-seam round guarded the timer observer's onNodeEnd but left onNodeStart constructing a TimerScope unconditionally, so every handler node of every cache operation still read the clock and allocated a token that the chain then discarded - the default configuration, one allocation per node. Return null on the disabled path, which is what ChainEngine already pairs back and what this observer did before the non-null seam existed. The observer's class Javadoc and the onNodeEnd comment now state both null-token sources (disabled seam, or a failed start hook) instead of only the latter. Red-first: ChainObserverTest.noopSeam_doesNotAllocateScopeToken fails with "expected: null" against the pre-fix onNodeStart and passes after. Co-Authored-By: Claude Code --- CHANGELOG.md | 18 ++++++++++++------ .../redis/cache/ChainTimerChainObserver.java | 13 ++++++++----- .../cache/redis/cache/ChainObserverTest.java | 12 ++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcfa237f..9dcfcd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,13 +259,19 @@ Current milestones: `RedisCacheHealthIndicator` now reports Redis connectivity and protection degradation regardless of `resi-cache.metrics.enabled`; previously the unrelated metrics switch could suppress the indicator. -- **The disabled metrics seam retains nothing (c2)** — with +- **The disabled metrics seam does no work (c2)** — with `resi-cache.metrics.enabled` off (the default) the resolved seam is a shared - stateless registry, and the chain's timer observer, the fired-counter observer - and the failure reporter short-circuit on it. A disabled application therefore - allocates and keeps no meter, no timer and no per-cache entry: an earlier - revision of this change had moved that retention into the timer observer's own - per-cache-name map. Metric names, tag keys and tag values are unchanged. + stateless registry, and every consumer short-circuits on it rather than + registering through it: the chain's timer observer, the fired-counter observer + and the failure reporter return before building a key; the per-cache registry, + the handler attach hook, the refresh-task metrics and the Bloom filter take + their existing null path; and the migration engine skips its per-key metric + record. A disabled application therefore allocates and keeps no meter, no + timer and no per-cache entry, and the chain timer observer neither reads the + clock nor allocates a scope token on that path. Two earlier revisions of this + change had moved that work instead of removing it — the retention into the + timer observer's own per-cache-name map, and the per-key allocation into the + migration engine. Metric names, tag keys and tag values are unchanged. - **The degraded-protection warning fires once per context (c2)** — `RedisCacheHealthIndicator` emits `protection.degraded=local-only` on its first degraded observation instead of on every `/actuator/health` probe, which matters diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java index 7b272462..cb2de390 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/ChainTimerChainObserver.java @@ -30,8 +30,8 @@ * *

        线程安全:Timer map 支持并发注册;{@link TimerScope} 是单次节点调用的不可变 * token,不在 observer 内保存共享的 per-call 状态。registry 由 {@link ResolvedMetrics} - * 单一决议、永不为 null;metrics 未启用时它是 no-op seam,关闭路径不注册、不分配、 - * 不保留任何 timer —— map 保持为空。 + * 单一决议、永不为 null;metrics 未启用时它是 no-op seam,关闭路径不读时钟、不分配 + * scope token、不注册、不分配、不保留任何 timer —— map 保持为空,节点起点返回 null。 */ @Order(3) // 执行顺序单一真值源=类级 @Order,见 MDCStampChainObserver 注释 final class ChainTimerChainObserver implements ChainObserver { @@ -50,15 +50,18 @@ public ChainTimerChainObserver(MeterRegistry registry) { @Override public Object onNodeStart(CacheHandler handler, CacheContext context) { - return new TimerScope(System.nanoTime()); + // 关闭路径不分配 token:Engine 按 index 配对回传 null,onNodeEnd 直接返回, + // 与 metrics 未启用时的历史行为一致(既不计时,也不读时钟)。 + return disabled ? null : new TimerScope(System.nanoTime()); } @Override public void onNodeEnd(CacheHandler handler, CacheContext context, Object scopeToken, HandlerResult result) { if (disabled || result == null || scopeToken == null) { - // 故障节点没有 HandlerResult,不伪造 decision;token 为 null 仅当本人 - // onNodeStart 抛异常(Engine 不产生 token),同样无样本可记录。 + // 故障节点没有 HandlerResult,不伪造 decision;token 为 null 有两处来源: + // 本 observer 在关闭 seam 时不分配 token,或本人 onNodeStart 抛异常 + // (Engine 不产生 token)。两种情形都无样本可记录。 // disabled:关闭路径不构造 TimerKey、不写 map、不分配 NoopTimer。 return; } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java index ad79997a..506ed715 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/ChainObserverTest.java @@ -149,6 +149,18 @@ void noopSeam_retainsNoTimers() { assertThat(observer.registeredTimerCount()).isZero(); } + /** + * map 为空还不够:关闭 seam 时 onNodeStart 若仍构造 scope token,每个 handler 节点 + * 都会白读一次时钟并分配一个立刻被丢弃的 token —— 关闭路径应当连工作都不做。 + */ + @Test + @DisplayName("disabled seam 下节点起点不分配 scope token") + void noopSeam_doesNotAllocateScopeToken() { + ChainTimerChainObserver observer = new ChainTimerChainObserver(noOpSeam()); + + assertThat(observer.onNodeStart(new ContinueHandler(), ctx)).isNull(); + } + @Test @DisplayName("成功节点按 handler、decision、cacheName 记录一次 Timer") void successfulNode_recordsBoundedTags() { From 5bf6adfb20990e486cfc21af81ced5cea4cff985 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 22 Sep 2026 17:07:31 +0800 Subject: [PATCH 56/56] fix(cache): report the real sync-protection mode in the health indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncSupport.isDegraded() was true exactly when no lock backend existed and local-only was NOT enabled — the fail-fast state — yet the indicator labeled that observation protection.degraded=local-only, while the explicit local-only degradation it names reported nothing. SyncSupport now owns one ProtectionMode derivation (DISTRIBUTED / LOCAL_ONLY / FAIL_FAST); the indicator attaches the matching detail per mode, warns at most once per mode, and omits protection details when a backend exists. CHANGELOG and OPERATIONS updated to the shipped semantics. --- CHANGELOG.md | 17 +++-- docs/OPERATIONS.md | 7 +- .../cache/RedisCacheHealthIndicator.java | 53 ++++++++----- .../spring/cache/redis/cache/SyncSupport.java | 31 ++++++-- .../cache/RedisCacheHealthIndicatorTest.java | 75 ++++++++++++++++++- .../cache/redis/cache/SyncSupportTest.java | 15 ++++ 6 files changed, 162 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dcfcd11..8e7f9575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -272,12 +272,17 @@ Current milestones: change had moved that work instead of removing it — the retention into the timer observer's own per-cache-name map, and the per-key allocation into the migration engine. Metric names, tag keys and tag values are unchanged. -- **The degraded-protection warning fires once per context (c2)** — - `RedisCacheHealthIndicator` emits `protection.degraded=local-only` on its first - degraded observation instead of on every `/actuator/health` probe, which matters - where a load balancer or orchestrator probes frequently and no distributed lock - backend is installed. Level and wording are unchanged, and the degradation is - still reported in every health response's details. +- **The degraded-protection warning fires once per context and reports the real + mode (c2)** — `RedisCacheHealthIndicator` reports the actual sync-protection + state, derived in one place by `SyncSupport`: `protection.degraded=local-only` + only when no distributed lock backend is present and + `resi-cache.sync-lock.local-only=true` was explicitly enabled, + `protection.degraded=fail-fast` when no backend is present without that opt-in + (so `sync=true` rejects instead of degrading), and no protection detail when a + backend exists. The associated WARN fires at most once per context instead of + on every `/actuator/health` probe, which matters where a load balancer or + orchestrator probes frequently; the state itself is reported in every health + response's details. - **Observer order owned by the observer class (c3)** — the four standard observers declare `@Order(1..4)` on the class instead of on their `@Bean` methods, and the factory registers observers in the Spring-resolved injection diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 583316e9..2c2ab8c0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -44,7 +44,12 @@ and the application's `MeterRegistry`, a decision resolved once during assembly. When either is missing the metrics seam is a no-op adapter and nothing is published. The Redis health indicator is not gated by that switch; it needs the optional Actuator dependency and reports Redis connectivity plus -protection degradation. +the sync-protection state: `protection.degraded=local-only` when no distributed +lock backend is present and `resi-cache.sync-lock.local-only=true` was +explicitly enabled, `protection.degraded=fail-fast` when no backend is present +without that opt-in, and no protection detail when a backend exists. The +indicator's overall status tracks Redis connectivity only: it stays UP while a +protection detail is attached. Because that indicator is assembled whenever Actuator, Redis and ResiCache are all present, **every `/actuator/health` probe costs one synchronous diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java index 2f3dbf9d..4f74b564 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicator.java @@ -4,7 +4,7 @@ import lombok.extern.slf4j.Slf4j; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.health.contributor.Health; @@ -19,12 +19,14 @@ *

        健康级联: *

          *
        1. Redis PING(基础 — Redis 自身连通性)
        2. - *
        3. Protection 机制级联 — sync=true 但无分布式锁后端(Redisson 缺失)时, - * 报告 {@code protection.degraded=local-only}(不阻断整体 UP 状态, - * 但暴露安全降级便于运维感知)。本类标 {@code Status.UP} + detail 记录
        4. + *
        5. Protection 机制级联 — 报告 {@link SyncSupport.ProtectionMode} 的实测模式, + * 均不阻断整体 UP 状态:无分布式锁后端且显式 {@code local-only=true} 时报告 + * {@code protection.degraded=local-only}(单 JVM 同步,跨实例不协调);无后端且 + * 未启用 local-only 时报告 {@code protection.degraded=fail-fast}(sync=true 操作 + * 将在首次未命中直接失败)。后端存在时不附 protection detail
        6. *
        * - *

        本指标报告的是 Redis 连通性与 protection 降级,与 metrics 无关,故只按 + *

        本指标报告的是 Redis 连通性与 sync 保护状态,与 metrics 无关,故只按 * Actuator 是否在 classpath 上启用(见 {@code @ConditionalOnClass})。 */ @Slf4j @@ -34,8 +36,8 @@ class RedisCacheHealthIndicator implements HealthIndicator { private final RedisTemplate redisCacheTemplate; private final SyncSupport syncSupport; - /** 降级 WARN 每 context 至多一条 —— 状态本身仍每次响应都报告在 detail 中。 */ - private final AtomicBoolean degradationWarned = new AtomicBoolean(); + /** 每种非 DISTRIBUTED 模式至多一条 WARN —— 状态本身仍每次响应都报告在 detail 中。 */ + private final AtomicReference warnedMode = new AtomicReference<>(); public RedisCacheHealthIndicator(RedisTemplate redisCacheTemplate, ObjectProvider syncSupportProvider) { @@ -57,18 +59,33 @@ public Health health() { } // protection 机制健康(仅在 Redis 自身 UP 时检查) - if (syncSupport != null && syncSupport.isDegraded()) { - // sync=true 但无分布式锁后端 — 降级为 local-only(单 JVM 锁,跨实例不协调) - // 状态仍是 UP(Redis 可用),但 detail 记录 protection.degraded - // 降级状态在构造期即固定(LockManager 列表 + local-only 属性),而 health 端点按探针 - // 节奏被反复调用 —— 每探针一条恒同 WARN 只是噪声;状态本身仍在每次响应 detail 中报告。 - if (degradationWarned.compareAndSet(false, true)) { - log.warn("protection.degraded=local-only: sync=true 但无分布式锁后端,降级为单 JVM synchronized"); + if (syncSupport != null) { + // detail 报告 SyncSupport 实测的保护模式;后端列表构造期固定,local-only 属性 + // 实时读取,而 health 端点按探针节奏被反复调用 —— 同一模式的恒同 WARN 只是 + // 噪声,每模式至多一条;状态本身仍在每次响应 detail 中报告。 + SyncSupport.ProtectionMode mode = syncSupport.protectionMode(); + if (mode != SyncSupport.ProtectionMode.DISTRIBUTED + && !mode.equals(warnedMode.getAndSet(mode))) { + log.warn(mode == SyncSupport.ProtectionMode.LOCAL_ONLY + ? "protection.degraded=local-only: 无分布式锁后端,已按 local-only=true 显式降级为单 JVM synchronized" + : "protection.degraded=fail-fast: 无分布式锁后端且未启用 local-only,sync=true 操作将在首次未命中直接失败"); + } + if (mode == SyncSupport.ProtectionMode.LOCAL_ONLY) { + builder = builder + .withDetail("protection.degraded", "local-only") + .withDetail("protection.degraded.reason", + "no distributed LockManager bean and " + + "resi-cache.sync-lock.local-only=true: sync=true " + + "degrades to single-JVM synchronized " + + "(not multi-instance protection)"); + } else if (mode == SyncSupport.ProtectionMode.FAIL_FAST) { + builder = builder + .withDetail("protection.degraded", "fail-fast") + .withDetail("protection.degraded.reason", + "no distributed LockManager bean and " + + "resi-cache.sync-lock.local-only=false: sync=true " + + "operations fail fast on the first cache miss"); } - builder = builder - .withDetail("protection.degraded", "local-only") - .withDetail("protection.degraded.reason", - "sync=true configured but no distributed lock manager (Redisson) on classpath"); } return builder.build(); diff --git a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java index d0fb3a90..c3e30e63 100644 --- a/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java +++ b/src/main/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupport.java @@ -45,6 +45,9 @@ *

        注意:{@code sync=true} 是 per-method 注解属性,启动期不可穷举,故 fail-fast 的精确触发点 * 在运行期 {@link #executeSync}(即用户确实声明了 sync 且缓存未命中);启动期仅在检测到空后端时 * 发出告警(见 {@link #warnIfNoDistributedBackend()}),仍允许启动(用户可能根本不用 sync)。 + * + *

        本类的 {@link #protectionMode()} 同时是健康侧( {@code RedisCacheHealthIndicator} ) + * 报告 sync 保护状态的唯一推导点。 */ @Slf4j @Component @@ -93,17 +96,29 @@ private void warnIfNoDistributedBackend() { } } + /** sync 保护的后端实测模式(构造期即固定:后端列表 + local-only 属性)。 */ + enum ProtectionMode { + /** 分布式锁后端存在,sync=true 按设计工作。 */ + DISTRIBUTED, + /** 无后端且显式 {@code local-only=true}:sync=true 降级为单 JVM 同步。 */ + LOCAL_ONLY, + /** 无后端且未启用 local-only:sync=true 首次未命中即 fail-fast。 */ + FAIL_FAST + } + /** - * 健康查询:同步锁后端是否缺失。{@code true} = 未显式 - * {@code localOnly=true} 且无分布式锁后端(Redisson 缺失);此时 sync=true - * 首次未命中会 fail-fast。暴露此信号供 - * {@code RedisCacheHealthIndicator} 级联到 /actuator/health。 + * 健康查询:sync 保护的实测模式。后端存在性与 {@code local-only} 属性都归本类 + * 所有,消费方(如 {@code RedisCacheHealthIndicator})不重复推导。 * - * @return 是否处于缺失分布式后端且未显式允许 local-only 的状态 + * @return 当前保护模式 */ - public boolean isDegraded() { - return !properties.getSyncLock().isLocalOnly() - && distributedManagers.isEmpty(); + public ProtectionMode protectionMode() { + if (!distributedManagers.isEmpty()) { + return ProtectionMode.DISTRIBUTED; + } + return properties.getSyncLock().isLocalOnly() + ? ProtectionMode.LOCAL_ONLY + : ProtectionMode.FAIL_FAST; } /** diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java index 34f77eb2..417036b4 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisCacheHealthIndicatorTest.java @@ -28,7 +28,7 @@ void reportsConnectivityAndProtectionDegradation() { RedisTemplate template = mock(RedisTemplate.class); SyncSupport syncSupport = mock(SyncSupport.class); when(template.execute(any(RedisCallback.class))).thenReturn("PONG"); - when(syncSupport.isDegraded()).thenReturn(true); + when(syncSupport.protectionMode()).thenReturn(SyncSupport.ProtectionMode.LOCAL_ONLY); RedisCacheHealthIndicator indicator = new RedisCacheHealthIndicator( template, provider(syncSupport)); @@ -42,13 +42,51 @@ void reportsConnectivityAndProtectionDegradation() { .containsKey("protection.degraded.reason"); } + @Test + @DisplayName("fail-fast state reports fail-fast, not local-only") + void failFastState_reportsFailFastDetail() { + RedisTemplate template = mock(RedisTemplate.class); + SyncSupport syncSupport = mock(SyncSupport.class); + when(template.execute(any(RedisCallback.class))).thenReturn("PONG"); + when(syncSupport.protectionMode()).thenReturn(SyncSupport.ProtectionMode.FAIL_FAST); + + Health health = new RedisCacheHealthIndicator( + template, provider(syncSupport)).health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()) + .containsEntry("protection.degraded", "fail-fast") + .containsEntry("protection.degraded.reason", + "no distributed LockManager bean and " + + "resi-cache.sync-lock.local-only=false: sync=true " + + "operations fail fast on the first cache miss"); + } + + @Test + @DisplayName("distributed backend reports connectivity without protection detail") + void distributedBackend_omitsProtectionDetail() { + RedisTemplate template = mock(RedisTemplate.class); + SyncSupport syncSupport = mock(SyncSupport.class); + when(template.execute(any(RedisCallback.class))).thenReturn("PONG"); + when(syncSupport.protectionMode()).thenReturn(SyncSupport.ProtectionMode.DISTRIBUTED); + + Health health = new RedisCacheHealthIndicator( + template, provider(syncSupport)).health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()) + .containsEntry("status", "connected") + .doesNotContainKey("protection.degraded") + .doesNotContainKey("protection.degraded.reason"); + } + @Test @DisplayName("degraded state is reported on every probe but warns only once per context") void degradedState_warnsOncePerContext() { RedisTemplate template = mock(RedisTemplate.class); SyncSupport syncSupport = mock(SyncSupport.class); when(template.execute(any(RedisCallback.class))).thenReturn("PONG"); - when(syncSupport.isDegraded()).thenReturn(true); + when(syncSupport.protectionMode()).thenReturn(SyncSupport.ProtectionMode.LOCAL_ONLY); RedisCacheHealthIndicator indicator = new RedisCacheHealthIndicator( template, provider(syncSupport)); @@ -65,7 +103,8 @@ void degradedState_warnsOncePerContext() { .filteredOn(event -> event.getLevel() == Level.WARN) .singleElement() .satisfies(event -> assertThat(event.getFormattedMessage()) - .isEqualTo("protection.degraded=local-only: sync=true 但无分布式锁后端,降级为单 JVM synchronized")); + .isEqualTo("protection.degraded=local-only: 无分布式锁后端," + + "已按 local-only=true 显式降级为单 JVM synchronized")); assertThat(List.of(first, second)).allSatisfy(health -> { assertThat(health.getStatus()).isEqualTo(Status.UP); assertThat(health.getDetails()) @@ -77,6 +116,36 @@ void degradedState_warnsOncePerContext() { } } + @Test + @DisplayName("fail-fast state warns once per context") + void failFastState_warnsOncePerContext() { + RedisTemplate template = mock(RedisTemplate.class); + SyncSupport syncSupport = mock(SyncSupport.class); + when(template.execute(any(RedisCallback.class))).thenReturn("PONG"); + when(syncSupport.protectionMode()).thenReturn(SyncSupport.ProtectionMode.FAIL_FAST); + + RedisCacheHealthIndicator indicator = new RedisCacheHealthIndicator( + template, provider(syncSupport)); + + Logger logger = (Logger) LoggerFactory.getLogger(RedisCacheHealthIndicator.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + indicator.health(); + indicator.health(); + + assertThat(appender.list) + .filteredOn(event -> event.getLevel() == Level.WARN) + .singleElement() + .satisfies(event -> assertThat(event.getFormattedMessage()) + .isEqualTo("protection.degraded=fail-fast: 无分布式锁后端且未启用 " + + "local-only,sync=true 操作将在首次未命中直接失败")); + } finally { + logger.detachAppender(appender); + } + } + @Test @DisplayName("reports unexpected ping response as down") void reportsUnexpectedPingResponseAsDown() { diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupportTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupportTest.java index 2c9e79ba..02bff6fa 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupportTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/SyncSupportTest.java @@ -76,6 +76,21 @@ void executeSync_noManagers_localOnly_degradesToJvm() { assertThat(result).isEqualTo("value"); } + @Test + @DisplayName("protectionMode reports the actual sync-protection state for health reporting") + void protectionMode_tracksBackendAndLocalOnly() { + properties.getSyncLock().setLocalOnly(true); + assertThat(new SyncSupport(new ArrayList<>(), properties).protectionMode()) + .isEqualTo(SyncSupport.ProtectionMode.LOCAL_ONLY); + + properties.getSyncLock().setLocalOnly(false); + assertThat(new SyncSupport(new ArrayList<>(), properties).protectionMode()) + .isEqualTo(SyncSupport.ProtectionMode.FAIL_FAST); + + assertThat(new SyncSupport(new ArrayList<>(List.of(lockManager)), properties).protectionMode()) + .isEqualTo(SyncSupport.ProtectionMode.DISTRIBUTED); + } + @Test @DisplayName("returns result when lock acquired successfully") void executeSync_lockAcquired_returnsResult() throws InterruptedException {