From ee716775622c19bdd964ec4f7ea16eb8fb647afe Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Fri, 18 Sep 2026 17:01:27 +0800 Subject: [PATCH 1/6] refactor(config): make internal bean ownership single-source --- .../cache/RedisProCacheConfiguration.java | 23 ++----------------- ...edisProCacheConfigurationContractTest.java | 7 +++++- 2 files changed, 8 insertions(+), 22 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 5433d5f1..964bf302 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,27 +29,8 @@ @Slf4j @Configuration(proxyBeanMethods = false) @Import({ - RedisCacheAttributesProjector.class, - SpringCacheableAdapter.class, - CacheHandlerChain.class, - CacheHandlerChainFactory.class, - ChainEngine.class, - ActualCacheHandler.class, - TtlHandler.class, - NullValueEncoder.class, - NullValueHandler.class, - BloomFilterHandler.class, - BloomGate.class, - BloomSupport.class, - SyncLockHandler.class, - SyncLockTimeout.class, - SyncSupport.class, - EarlyExpirationHandler.class, - SecureJacksonSerializerFactory.class, - CacheValueCodec.class, - SerializationPreFlightProbe.class, - SerializerWhitelistStartupGuard.class, - TlsConfigurationValidator.class, + // RedisCacheAutoConfiguration scans the internal runtime package. These two + // configurations are the intentional scan exclusions and remain explicit. ResolvedMetricsConfiguration.class, RedisProxyCachingConfiguration.class }) 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 9b009c49..72943247 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 @@ -131,7 +131,9 @@ void productionConfiguration_importsInternalConfigurationsExplicitly() { assertThat(configurationImport).isNotNull(); assertThat(configurationImport.value()) - .contains(RedisProxyCachingConfiguration.class, ResolvedMetricsConfiguration.class); + .containsExactlyInAnyOrder( + RedisProxyCachingConfiguration.class, + ResolvedMetricsConfiguration.class); } @Test @@ -232,6 +234,9 @@ void defaultAssembly_createsProxyAndCacheManager_withoutUserOverrides() throws E assertThat(context).hasBean("cacheManager"); assertThat(context).hasBean("redisCacheAdvisor"); assertThat(context).hasBean("redisCacheInterceptor"); + assertThat(context).hasSingleBean(CacheHandlerChainFactory.class); + assertThat(context).hasSingleBean(ChainEngine.class); + assertThat(context).doesNotHaveBean(CacheHandlerChain.class); }); } } From 64852e8955be4caf03422bf4b92e85bf37efa400 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Fri, 18 Sep 2026 17:14:40 +0800 Subject: [PATCH 2/6] refactor(cache): share async writer submission seam --- .../redis/cache/RedisProCacheWriter.java | 27 ++--- .../cache/RedisProCacheWriterAsyncTest.java | 113 ++++++++++++++++++ 2 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterAsyncTest.java 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 45b51630..b7007320 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 @@ -14,6 +14,7 @@ import java.time.Duration; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.data.redis.cache.CacheStatistics; @@ -150,13 +151,15 @@ public CompletableFuture retrieve(@NonNull String name, @NonNull byte[] @NonNull public CompletableFuture retrieve( @NonNull String name, @NonNull byte[] key, @Nullable Duration ttl) { + return submitAsync(() -> get(name, key, ttl)); + } + + private CompletableFuture submitAsync(Supplier work) { MethodSnapshot snapshot = operationResolver == null ? null : operationResolver.capture(); Map mdcSnapshot = MDC.getCopyOfContextMap(); - return CompletableFuture.supplyAsync( - () -> operationResolver == null - ? get(name, key, ttl) - : operationResolver.runWithSnapshot( - snapshot, mdcSnapshot, () -> get(name, key, ttl))); + return CompletableFuture.supplyAsync(() -> operationResolver == null + ? work.get() + : operationResolver.runWithSnapshot(snapshot, mdcSnapshot, work)); } @Override @@ -179,17 +182,9 @@ public CompletableFuture store( @NonNull byte[] key, @NonNull byte[] value, @Nullable Duration ttl) { - MethodSnapshot snapshot = operationResolver == null ? null : operationResolver.capture(); - Map mdcSnapshot = MDC.getCopyOfContextMap(); - return CompletableFuture.runAsync(() -> { - if (operationResolver == null) { - put(name, key, value, ttl); - return; - } - operationResolver.runWithSnapshot(snapshot, mdcSnapshot, () -> { - put(name, key, value, ttl); - return null; - }); + return submitAsync(() -> { + put(name, key, value, ttl); + return null; }); } diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterAsyncTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterAsyncTest.java new file mode 100644 index 00000000..9b4aa5b7 --- /dev/null +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisProCacheWriterAsyncTest.java @@ -0,0 +1,113 @@ +package io.github.davidhlp.spring.cache.redis.cache; + +import io.github.davidhlp.spring.cache.redis.chain.CacheOperation; +import io.github.davidhlp.spring.cache.redis.chain.CacheResult; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.MDC; +import org.springframework.data.redis.cache.CacheStatisticsCollector; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.same; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RedisProCacheWriterAsyncTest { + + @Mock + private CacheStatisticsCollector statistics; + + @Mock + private CacheValueCodec valueCodec; + + @Mock + private CacheHandlerChainFactory chainFactory; + + @Mock + private CacheHandlerChain chain; + + @Mock + private CacheOperationResolver operationResolver; + + private RedisProCacheWriter writer; + + @BeforeEach + void setUp() { + when(chainFactory.createChain()).thenReturn(chain); + writer = new RedisProCacheWriter( + statistics, valueCodec, chainFactory, operationResolver); + doAnswer(invocation -> { + Supplier work = invocation.getArgument(2); + return work.get(); + }).when(operationResolver).runWithSnapshot(any(), any(), any()); + } + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void retrieve_capturesOnceAndPassesSnapshotAndMdcToResolver() throws Exception { + MethodSnapshot snapshot = snapshot("retrieve"); + Map mdc = Map.of("traceId", "retrieve-trace"); + byte[] expected = "cached".getBytes(); + when(operationResolver.capture()).thenReturn(snapshot); + when(chain.execute(any())).thenReturn(CacheResult.success(expected)); + MDC.setContextMap(mdc); + + assertThat(writer.retrieve("cache", "key".getBytes(), Duration.ofSeconds(1)).join()) + .containsExactly(expected); + + verify(operationResolver, times(1)).capture(); + verify(operationResolver, times(1)) + .runWithSnapshot(same(snapshot), eq(mdc), any()); + } + + @Test + void store_capturesOnceAndPutFailureCompletesFutureExceptionally() throws Exception { + MethodSnapshot snapshot = snapshot("store"); + Map mdc = Map.of("traceId", "store-trace"); + IllegalStateException cause = new IllegalStateException("redis down"); + when(operationResolver.capture()).thenReturn(snapshot); + when(chain.execute(any())).thenReturn( + CacheResult.failure(CacheOperation.PUT, CacheResult.FailureKind.REDIS, cause)); + MDC.setContextMap(mdc); + + CompletableFuture future = writer.store( + "cache", "key".getBytes(), "value".getBytes(), Duration.ofSeconds(1)); + + assertThatThrownBy(future::join) + .isInstanceOf(java.util.concurrent.CompletionException.class) + .hasCauseInstanceOf(CacheOperationException.class); + assertThat(future).isCompletedExceptionally(); + verify(operationResolver, times(1)).capture(); + verify(operationResolver, times(1)) + .runWithSnapshot(same(snapshot), eq(mdc), any()); + } + + private static MethodSnapshot snapshot(String methodName) throws Exception { + Method method = Fixture.class.getDeclaredMethod(methodName); + return MethodSnapshot.of(method, Fixture.class); + } + + static final class Fixture { + void retrieve() { } + void store() { } + } +} From 42aa26735b588b488732d50a4f0919a40cefc005 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Fri, 18 Sep 2026 17:14:08 +0800 Subject: [PATCH 3/6] refactor: share async writer submission lifecycle --- .../redis/cache/RedisProCacheWriter.java | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) 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 45b51630..d62b298f 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 @@ -14,6 +14,7 @@ import java.time.Duration; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.data.redis.cache.CacheStatistics; @@ -140,6 +141,14 @@ public boolean supportsAsyncRetrieve() { return true; } + private CompletableFuture submitAsync(Supplier work) { + MethodSnapshot snapshot = operationResolver == null ? null : operationResolver.capture(); + Map mdcSnapshot = MDC.getCopyOfContextMap(); + return CompletableFuture.supplyAsync( + () -> operationResolver == null + ? work.get() + : operationResolver.runWithSnapshot(snapshot, mdcSnapshot, work)); + } @Override @NonNull public CompletableFuture retrieve(@NonNull String name, @NonNull byte[] key) { @@ -150,13 +159,7 @@ public CompletableFuture retrieve(@NonNull String name, @NonNull byte[] @NonNull public CompletableFuture retrieve( @NonNull String name, @NonNull byte[] key, @Nullable Duration ttl) { - MethodSnapshot snapshot = operationResolver == null ? null : operationResolver.capture(); - Map mdcSnapshot = MDC.getCopyOfContextMap(); - return CompletableFuture.supplyAsync( - () -> operationResolver == null - ? get(name, key, ttl) - : operationResolver.runWithSnapshot( - snapshot, mdcSnapshot, () -> get(name, key, ttl))); + return submitAsync(() -> get(name, key, ttl)); } @Override @@ -179,17 +182,9 @@ public CompletableFuture store( @NonNull byte[] key, @NonNull byte[] value, @Nullable Duration ttl) { - MethodSnapshot snapshot = operationResolver == null ? null : operationResolver.capture(); - Map mdcSnapshot = MDC.getCopyOfContextMap(); - return CompletableFuture.runAsync(() -> { - if (operationResolver == null) { - put(name, key, value, ttl); - return; - } - operationResolver.runWithSnapshot(snapshot, mdcSnapshot, () -> { - put(name, key, value, ttl); - return null; - }); + return submitAsync(() -> { + put(name, key, value, ttl); + return null; }); } From d0452b1066236f25641ed4513ba9dfe64d4c6d82 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Fri, 18 Sep 2026 17:14:55 +0800 Subject: [PATCH 4/6] test(metrics): lock write-back failure count-once boundary --- .../redis/cache/CacheFailureReporter.java | 6 +- .../cache/RedisProCacheLoadPathTest.java | 58 +++++++++++++++---- 2 files changed, 51 insertions(+), 13 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 2aa5d8cd..e35cce87 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 @@ -20,9 +20,9 @@ * {@code strategy}({@link ErrorStrategy})。禁止 cacheName / key / message tag * (高基数),WARN/ERROR 日志与异常消息默认不含 raw key(由调用方保证)。 * - *

计数去重:同一失败事件只经本类一次上报 — 调用方(链层 - * {@code CacheErrorHandler}、read-through write-back)各自在唯一失败出口 - * 调用一次,不重复计数。 + *

计数去重:由 writer PUT chain 产生的写回失败只经本类一次上报 — 链内 + * {@code CacheErrorHandler} 是唯一报告出口;{@code LoaderOrchestrator} 只保留脱敏 WARN + * 与 {@code LoadedWithWriteBackFailure},不重复计数。 * *

边界(ADR-06):本指标只统计缓存操作失败(GET degrade / 写 fail-fast / * REMOVE best-effort / write-back failure)。Bloom 过滤器底层 Redis 位操作的 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 7604d686..01d37aec 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 @@ -137,16 +137,34 @@ private static final class MemoryWriter implements RedisCacheWriter { } private RedisProCache cacheWith(RedisCacheWriter writer) { + return cacheWith(writer, new SimpleMeterRegistry()); + } + + private RedisProCache cacheWith( + RedisCacheWriter writer, SimpleMeterRegistry registry) { return new RedisProCache(CACHE_NAME, writer, RedisCacheConfiguration.defaultCacheConfig(), - ResiCacheFeatures.builder().meterRegistry(new SimpleMeterRegistry()).build()); + ResiCacheFeatures.builder().meterRegistry(registry).build()); } + private RedisProCacheWriter writerWithPutFailure() { - return writerWithPutFailure(false); + return writerWithPutFailure(null, false); } private RedisProCacheWriter writerWithPutFailure(boolean illegalArgument) { + return writerWithPutFailure(null, illegalArgument); + } + + private RedisProCacheWriter writerWithPutFailure(SimpleMeterRegistry registry) { + return writerWithPutFailure(registry, false); + } + + private RedisProCacheWriter writerWithPutFailure( + SimpleMeterRegistry registry, boolean illegalArgument) { when(chainFactory.createChain()).thenReturn(chain); + CacheErrorHandler errorHandler = registry == null + ? new CacheErrorHandler() + : new CacheErrorHandler(new CacheFailureReporter(registry)); when(chain.execute(any(CacheContext.class))).thenAnswer(invocation -> { CacheContext context = invocation.getArgument(0); if (context.getOperation() == CacheOperation.GET) { @@ -155,9 +173,10 @@ private RedisProCacheWriter writerWithPutFailure(boolean illegalArgument) { if (illegalArgument) { throw new IllegalArgumentException("invalid write-back configuration"); } - return CacheResult.failure( - CacheOperation.PUT, - CacheResult.FailureKind.REDIS, + return errorHandler.handleError( + context.getOperation(), + context.getCacheName(), + context.getRedisKey(), new IllegalStateException("redis put failed for key " + SENTINEL_KEY)); }); return new RedisProCacheWriter( @@ -167,6 +186,18 @@ private RedisProCacheWriter writerWithPutFailure(boolean illegalArgument) { null); } + private void assertSinglePutFailureCounter(SimpleMeterRegistry registry) { + assertThat(registry.find(CacheFailureReporter.METRIC_NAME).counters()) + .hasSize(1); + var counter = registry.find(CacheFailureReporter.METRIC_NAME) + .tag("operation", "PUT") + .tag("kind", "REDIS") + .tag("strategy", "FAIL_FAST") + .counter(); + assertThat(counter).isNotNull(); + assertThat(counter.count()).isEqualTo(1.0); + } + @Test @DisplayName("非 sync loader 写回走带 metrics 的 put(与 sync 路径同记账)") void nonSyncWriteBack_recordsPutMetrics() { @@ -231,17 +262,19 @@ void nonSyncWriteBackFailure_returnsLoaderValueWithRedactedWarning() { } } @Test - @DisplayName("cache 与 native writer 分别各写出一个规范 WARN") - void cacheAndWriter_eachEntryEmitsOneCanonicalWarning() { + @DisplayName("cache 与 native writer 的 chain 写回失败均保留值且 PUT failure 只计一次") + void chainWriteBackFailure_preservesValueAndReportsPutExactlyOnce() { Logger logger = (Logger) LoggerFactory.getLogger(LoaderOrchestrator.class); ListAppender cacheAppender = new ListAppender<>(); cacheAppender.start(); logger.addAppender(cacheAppender); try { - RedisProCache cache = cacheWith(new MemoryWriter(null, true)); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RedisProCache cache = cacheWith(writerWithPutFailure(registry), registry); assertThat(cache.get(SENTINEL_KEY, () -> "business-value")) .isEqualTo("business-value"); assertSingleCanonicalWarning(cacheAppender); + assertSinglePutFailureCounter(registry); } finally { logger.detachAppender(cacheAppender); } @@ -250,7 +283,8 @@ void cacheAndWriter_eachEntryEmitsOneCanonicalWarning() { writerAppender.start(); logger.addAppender(writerAppender); try { - RedisProCacheWriter writer = writerWithPutFailure(); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RedisProCacheWriter writer = writerWithPutFailure(registry); byte[] writerValue = writer.get( CACHE_NAME, SENTINEL_KEY.getBytes(StandardCharsets.UTF_8), @@ -261,6 +295,7 @@ void cacheAndWriter_eachEntryEmitsOneCanonicalWarning() { assertThat(new String(writerValue, StandardCharsets.UTF_8)) .isEqualTo("business-value"); assertSingleCanonicalWarning(writerAppender); + assertSinglePutFailureCounter(registry); } finally { logger.detachAppender(writerAppender); } @@ -289,7 +324,8 @@ void illegalArgumentWriteBack_propagatesRawWithoutToleranceWarning() { writerAppender.start(); logger.addAppender(writerAppender); try { - RedisProCacheWriter writer = writerWithPutFailure(true); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RedisProCacheWriter writer = writerWithPutFailure(registry, true); assertThatThrownBy(() -> writer.get( CACHE_NAME, SENTINEL_KEY.getBytes(StandardCharsets.UTF_8), @@ -301,6 +337,8 @@ void illegalArgumentWriteBack_propagatesRawWithoutToleranceWarning() { assertThat(writerAppender.list) .filteredOn(event -> event.getLevel() == Level.WARN) .isEmpty(); + assertThat(registry.find(CacheFailureReporter.METRIC_NAME).meters()) + .isEmpty(); } finally { logger.detachAppender(writerAppender); } From 1364bba1b3a52aa33dbab9c7e65bdf13b011e446 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Fri, 18 Sep 2026 17:22:12 +0800 Subject: [PATCH 5/6] test(metrics): cover read-through failure ownership --- .../cache/redis/cache/LoaderOrchestrator.java | 18 +++++++---- ...edisDownFaultInjectionIntegrationTest.java | 32 ++++++++++++++++++- 2 files changed, 43 insertions(+), 7 deletions(-) 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 1912e4b3..130ff26f 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 @@ -57,8 +57,11 @@ * * *

一条协议,两个入口:cache 与 writer 入口都走 {@link #readThrough} — - * 同一套 double-check 语义、同一套写回容错和单点 WARN。两者只在读值表示与 loader - * 异常翻译上不同;sync 路径另外把 cache 协议跑在 {@link SyncSupport} 的分布式锁内。 + * 同一套 double-check 语义、同一套写回容错和单点脱敏 WARN。若写回经由 writer 的 PUT + * chain 失败,失败指标已在 {@link CacheErrorHandler} 出口上报一次;本编排器只观察并返回 + * tolerated outcome,不再次上报。 + * 两者只在读值表示与 loader 异常翻译上不同;sync 路径另外把 cache 协议跑在 + * {@link SyncSupport} 的分布式锁内。 * *

状态:无可变状态。3 个共享依赖和 3 个 cache-specific callback 由 * {@code RedisProCache} 在构造期一次性绑定(指向 {@code super.createCacheKey} / @@ -66,7 +69,7 @@ * loader 和 operation。 * *

契约保真:异常翻译、键派生({@link CacheKeys})、{@code -1} 永久缓存哨兵、 - * null-value 缓存等契约逐字保留;caller-side switch 的 metric 自增保证各路径恰好 1 次 miss 计数。 + * caller-side switch 的 miss counter 自增保证各路径恰好 1 次 miss 计数。 */ @Slf4j final class LoaderOrchestrator { @@ -98,7 +101,8 @@ public record Loaded(@Nullable T value) implements LoadOutcome { * loader 成功,但缓存写回失败(ADR-02 availability-first)。 * *

{@code value} 仍为 loader 产出的业务值,必须返回给调用方;{@code cause} - * 为写回失败的原始异常,供 caller 记录诊断与失败指标,不覆盖返回值。 + * 为写回失败的原始异常,供 caller 记录诊断;若失败来自 writer PUT chain,指标已由 + * {@link CacheErrorHandler} 上报一次,本 outcome 不重复计数。 * 锁内 double-check 命中(他线程已加载)不会产生本 outcome —— 该路径无写回。 */ public record LoadedWithWriteBackFailure(@Nullable T value, Throwable cause) @@ -273,8 +277,10 @@ private LoadOutcome executeLoad( * *

读侧表示({@code R})和业务值表示({@code T})由 caller 显式适配;因此 * {@link Cache.ValueWrapper} 与 writer 的 {@code byte[]} 不需要各自复制协议。 - * 写回失败只在此处容忍并发出一次脱敏 WARN;{@link IllegalArgumentException} 保持为 - * {@link LoadOutcome.LoadFailed},由 caller 原样抛出。 + * 写回若经由 writer 的 PUT chain 失败,链内 {@link CacheErrorHandler} 已完成一次 + * failure metric 上报;此处只容忍该失败并发出一次脱敏 WARN,返回 + * {@link LoadedWithWriteBackFailure},不再次调用 reporter。{@link IllegalArgumentException} + * 保持为 {@link LoadOutcome.LoadFailed},由 caller 原样抛出。 * * @param cacheName 缓存名称,仅用于脱敏诊断 * @param read 读原语 diff --git a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisDownFaultInjectionIntegrationTest.java b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisDownFaultInjectionIntegrationTest.java index 0f2c4ee6..f3e012d9 100644 --- a/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisDownFaultInjectionIntegrationTest.java +++ b/src/test/java/io/github/davidhlp/spring/cache/redis/cache/RedisDownFaultInjectionIntegrationTest.java @@ -3,6 +3,7 @@ +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -27,7 +28,9 @@ *

测试配置只替换 RedisConnectionFactory;生产自动配置不依赖 * {@link Primary} 作为用户 Bean 覆盖机制。 */ -@SpringBootTest(classes = {TestApplication.class, RedisDownFaultInjectionIntegrationTest.BrokenRedisConfig.class}) +@SpringBootTest( + classes = {TestApplication.class, RedisDownFaultInjectionIntegrationTest.BrokenRedisConfig.class}, + properties = "resi-cache.metrics.enabled=true") @ActiveProfiles({"integration-test", "redis-down-test"}) @Import(TestRedisConfiguration.class) @DisplayName("Redis 断连故障注入(GET 路径最小切片)") @@ -39,6 +42,18 @@ class RedisDownFaultInjectionIntegrationTest extends AbstractRedisIntegrationTes @Autowired private org.springframework.cache.CacheManager cacheManager; + @Autowired + private SimpleMeterRegistry meterRegistry; + + private double putFailureCount() { + var counter = meterRegistry.find(CacheFailureReporter.METRIC_NAME) + .tag("operation", "PUT") + .tag("kind", "REDIS") + .tag("strategy", "FAIL_FAST") + .counter(); + return counter == null ? 0.0 : counter.count(); + } + @Test @DisplayName("RedisDown-6: user-level Cache.get(key, loader) returns loader value on write-back failure") void redisDown_userLevelGetLoader_loaderValueSurvives() throws Exception { @@ -48,11 +63,15 @@ void redisDown_userLevelGetLoader_loaderValueSurvives() throws Exception { org.springframework.cache.Cache cache = cacheManager.getCache("testCache"); assertThat(cache).isNotNull(); + double beforePutFailure = putFailureCount(); String value = cache.get("user-level-loader-key", () -> "business-value"); assertThat(value) .as("用户级 read-through:loader 成功值必须穿透 Redis-down 写回失败返回") .isEqualTo("business-value"); + assertThat(putFailureCount() - beforePutFailure) + .as("cache read-through 的 PUT chain failure 只能在 CacheErrorHandler 计数一次") + .isEqualTo(1.0); } @Test @@ -105,6 +124,7 @@ void redisDown_get_degradesGracefully() throws Exception { void redisDown_writerReadThrough_loaderValueSurvivesWriteBackFailure() { // writer 级入口(getNativeCache() 可达):缓存读 miss → loader 成功 → 写回失败。 // availability-first:必须返回 loader 值,不得被写回失败覆盖。 + double beforePutFailure = putFailureCount(); byte[] result = writer.get( "testCache", "fault-injection-loader-key".getBytes(java.nio.charset.StandardCharsets.UTF_8), @@ -116,6 +136,9 @@ void redisDown_writerReadThrough_loaderValueSurvivesWriteBackFailure() { assertThat(new String(result, java.nio.charset.StandardCharsets.UTF_8)) .as("loader 成功值必须穿透写回失败返回") .isEqualTo("\"loaded-data\""); + assertThat(putFailureCount() - beforePutFailure) + .as("native writer read-through 的 PUT chain failure 只能在 CacheErrorHandler 计数一次") + .isEqualTo(1.0); } @Test @@ -147,10 +170,17 @@ static class BrokenRedisConfig { @Bean @Primary public RedisConnectionFactory brokenRedisConnectionFactory() { + // 端口 1 — 任何 host 都不会监听(privileged port,典型做法) LettuceConnectionFactory factory = new LettuceConnectionFactory("127.0.0.1", 1); factory.setTimeout(2000); // 2s timeout,避免测试 hang return factory; } + + @Bean + @Primary + public SimpleMeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } } } From d9899913f28260b6eeb653c6a55280d86378045a Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Fri, 18 Sep 2026 17:51:48 +0800 Subject: [PATCH 6/6] docs: clarify read-through failure metric ownership --- docs/adr/0001-interface-contract-closure.md | 24 ++++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/adr/0001-interface-contract-closure.md b/docs/adr/0001-interface-contract-closure.md index 7823d27b..7dd9e52e 100644 --- a/docs/adr/0001-interface-contract-closure.md +++ b/docs/adr/0001-interface-contract-closure.md @@ -343,14 +343,19 @@ not re-validated. **Context**: Failures were scattered across `CacheErrorHandler`, Bloom and read-through paths, counted mainly by logs; WARN/ERROR and exception messages -carried raw keys. +carried raw keys. The read-through write-back path may surface a writer +PUT-chain failure, so its metric ownership must remain explicit. **Decision**: A single internal `CacheFailureReporter` (not public, not in the allowlist) exposes one metric `resicache.cache.failure` tagged only by finite enums `operation`, `kind`, `strategy`. `CacheErrorHandler` is the single -count-once exit for all chain failures. WARN/ERROR and typed exception -messages omit the raw key; `cacheName` (config-level, low cardinality) is kept -for correlation. `CacheOperationException` carries no raw-key field/getter. +count-once exit for chain failures, including read-through write-back failures +that go through the writer's PUT chain. `LoaderOrchestrator` does not call the +reporter; it only emits a redacted WARN and returns +`LoadedWithWriteBackFailure`. Exceptions outside that chain are not implicitly +reclassified by this metric. WARN/ERROR and typed exception messages omit the +raw key; `cacheName` (config-level, low cardinality) is kept for correlation. +`CacheOperationException` carries no raw-key field/getter. Where a diagnostic has no `cacheName` (distributed-lock keys, single-flight role failures, async early-expiration retries) the raw key is replaced by @@ -368,10 +373,13 @@ and the full stack goes to DEBUG, because exception messages can embed the key stack with the message. **Consequences**: GET degrade, write fail-fast, REMOVE best-effort and -read-through write-back failures are alertable by bounded tags. The Bloom -filter's own `bloomsift.*` counters and fail-open paths are deliberately -*not* routed here — fail-open is a successful protection behavior, not a -cache-operation failure, so reporting it would corrupt degradation alerts. +writer-PUT-chain-originated read-through write-back failures are alertable by +bounded tags. `LoaderOrchestrator` preserves loaded values and emits only the +redacted tolerated-outcome warning, so the same failure is not counted twice. +The Bloom filter's own `bloomsift.*` counters and fail-open paths are +deliberately *not* routed here — fail-open is a successful protection behavior, +not a cache-operation failure, so reporting it would corrupt degradation +alerts. **Known limitation**: No per-key alerting; correlation relies on MDC requestId or the `keyFingerprint` token.