exists 原用 hasKey,会把持有空值标记的 key 判为"存在"(true),而 get 对同一 key 返回 null,二者口径打架。改为读值判断:空标记/缺失均返回 false,真实值返回 true。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
14 KiB
Java
343 lines
14 KiB
Java
package com.njcn.middle.cache.template;
|
|
|
|
import com.alibaba.fastjson.JSON;
|
|
import com.njcn.middle.cache.autoconfig.RedisCacheProperties;
|
|
import com.njcn.middle.cache.constant.CacheConstant;
|
|
import com.njcn.middle.cache.support.CacheKeyBuilder;
|
|
import com.njcn.middle.cache.support.CacheValueCodec;
|
|
import lombok.AllArgsConstructor;
|
|
import lombok.Data;
|
|
import lombok.NoArgsConstructor;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.mockito.Mock;
|
|
import org.mockito.MockitoAnnotations;
|
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
import org.springframework.data.redis.core.ValueOperations;
|
|
import org.springframework.data.redis.core.script.RedisScript;
|
|
|
|
import java.time.Duration;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
import java.util.function.Consumer;
|
|
import java.util.function.Supplier;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.anyList;
|
|
import static org.mockito.ArgumentMatchers.anyLong;
|
|
import static org.mockito.ArgumentMatchers.anyString;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.Mockito.atMost;
|
|
import static org.mockito.Mockito.lenient;
|
|
import static org.mockito.Mockito.never;
|
|
import static org.mockito.Mockito.verify;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
class RedisCacheEnhanceTemplateTest {
|
|
|
|
@Mock
|
|
StringRedisTemplate redis;
|
|
@Mock
|
|
ValueOperations<String, String> valueOps;
|
|
|
|
CacheValueCodec codec = new CacheValueCodec();
|
|
CacheKeyBuilder keyBuilder = new CacheKeyBuilder("cache:", false, "");
|
|
RedisCacheProperties props = new RedisCacheProperties();
|
|
RedisCacheEnhanceTemplate template;
|
|
|
|
@Data
|
|
@AllArgsConstructor
|
|
@NoArgsConstructor
|
|
static class User {
|
|
private Long id;
|
|
private String name;
|
|
}
|
|
|
|
@BeforeEach
|
|
void init() {
|
|
MockitoAnnotations.initMocks(this);
|
|
lenient().when(redis.opsForValue()).thenReturn(valueOps);
|
|
template = new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, props);
|
|
}
|
|
|
|
@Test
|
|
void setSerializesWithExactTtlWhenNoJitter() {
|
|
props.setJitterRatio(0);
|
|
User u = new User(1L, "a");
|
|
template.set("user:1", u, 100);
|
|
verify(valueOps).set("cache:user:1", JSON.toJSONString(u), 100L, TimeUnit.SECONDS);
|
|
}
|
|
|
|
@Test
|
|
void getHitDeserializes() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
|
User got = template.get("user:1", User.class);
|
|
assertEquals("a", got.getName());
|
|
}
|
|
|
|
@Test
|
|
void getMissReturnsNull() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
assertNull(template.get("user:1", User.class));
|
|
}
|
|
|
|
@Test
|
|
void getNullSentinelReturnsNull() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
|
assertNull(template.get("user:1", User.class));
|
|
}
|
|
|
|
@Test
|
|
void getDirtyDataThrowsIllegalState() {
|
|
when(valueOps.get("cache:user:1")).thenReturn("{not-json");
|
|
assertThrows(IllegalStateException.class, () -> template.get("user:1", User.class));
|
|
}
|
|
|
|
@Test
|
|
void expireDelegatesWithJitterTtl() {
|
|
props.setJitterRatio(0);
|
|
when(redis.expire("cache:user:1", 200L, TimeUnit.SECONDS)).thenReturn(true);
|
|
assertTrue(template.expire("user:1", 200));
|
|
verify(redis).expire("cache:user:1", 200L, TimeUnit.SECONDS);
|
|
}
|
|
|
|
@Test
|
|
void applyJitterWithinBounds() {
|
|
props.setJitterRatio(0.1);
|
|
long bound = (long) (100 * 0.1);
|
|
for (int i = 0; i < 50; i++) {
|
|
long ttl = template.applyJitter(100);
|
|
assertTrue(ttl >= 100 && ttl <= 100 + bound, "ttl=" + ttl);
|
|
}
|
|
props.setJitterRatio(0);
|
|
assertEquals(100, template.applyJitter(100));
|
|
}
|
|
|
|
@Test
|
|
void slidingOffDoesNotRenewOnGet() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
|
template.get("user:1", User.class);
|
|
verify(redis, never()).expire(any(), anyLong(), any());
|
|
}
|
|
|
|
@Test
|
|
void slidingOnRenewsRealValueOnGet() {
|
|
props.setSlidingExpireEnabled(true);
|
|
props.setJitterRatio(0);
|
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
|
when(redis.expire(eq("cache:user:1"), anyLong(), eq(TimeUnit.SECONDS))).thenReturn(true);
|
|
template.get("user:1", User.class);
|
|
verify(redis).expire("cache:user:1", props.getDefaultTtlSeconds(), TimeUnit.SECONDS);
|
|
}
|
|
|
|
@Test
|
|
void slidingOnDoesNotRenewNullSentinel() {
|
|
props.setSlidingExpireEnabled(true);
|
|
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
|
assertNull(template.get("user:1", User.class));
|
|
verify(redis, never()).expire(any(), anyLong(), any());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadHitDoesNotCallLoader() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
|
AtomicInteger calls = new AtomicInteger();
|
|
Supplier<User> loader = () -> {
|
|
calls.incrementAndGet();
|
|
return new User(9L, "x");
|
|
};
|
|
User got = template.getOrLoad("user:1", User.class, 60, loader);
|
|
assertEquals("a", got.getName());
|
|
assertEquals(0, calls.get());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadNullSentinelHitReturnsNullNoLoader() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
|
AtomicInteger calls = new AtomicInteger();
|
|
assertNull(template.getOrLoad("user:1", User.class, 60, () -> {
|
|
calls.incrementAndGet();
|
|
return new User(1L, "a");
|
|
}));
|
|
assertEquals(0, calls.get());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadMissAcquiresLockLoadsAndCaches() {
|
|
props.setJitterRatio(0);
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
|
.thenReturn(true);
|
|
User loaded = new User(1L, "a");
|
|
User got = template.getOrLoad("user:1", User.class, 60, () -> loaded);
|
|
assertEquals("a", got.getName());
|
|
verify(valueOps).set("cache:user:1", JSON.toJSONString(loaded), 60L, TimeUnit.SECONDS);
|
|
// 释放锁:执行了 Lua 脚本
|
|
verify(redis).execute(any(RedisScript.class), anyList(), any());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadLoaderReturnsNullWritesSentinel() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
|
.thenReturn(true);
|
|
assertNull(template.getOrLoad("user:1", User.class, 60, () -> null));
|
|
verify(valueOps).set("cache:user:1", CacheConstant.NULL_SENTINEL,
|
|
props.getNullTtlSeconds(), TimeUnit.SECONDS);
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadNotLockedWaitsThenReadsValue() {
|
|
props.getBreakdown().setWaitIntervalMs(1);
|
|
props.getBreakdown().setWaitTimeoutMs(1000);
|
|
// 初始 tryGet 返回 null,未抢到锁,随后等待中读到值
|
|
when(valueOps.get("cache:user:1"))
|
|
.thenReturn(null)
|
|
.thenReturn(null)
|
|
.thenReturn(JSON.toJSONString(new User(1L, "a")));
|
|
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
|
AtomicInteger calls = new AtomicInteger();
|
|
User got = template.getOrLoad("user:1", User.class, 60, () -> {
|
|
calls.incrementAndGet();
|
|
return new User(9L, "x");
|
|
});
|
|
assertEquals("a", got.getName());
|
|
assertEquals(0, calls.get());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadWaitTimeoutFallsBackToLoader() {
|
|
props.getBreakdown().setWaitIntervalMs(1);
|
|
props.getBreakdown().setWaitTimeoutMs(5);
|
|
when(valueOps.get("cache:user:1")).thenReturn(null); // 始终未命中
|
|
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
|
AtomicInteger calls = new AtomicInteger();
|
|
User got = template.getOrLoad("user:1", User.class, 60, () -> {
|
|
calls.incrementAndGet();
|
|
return new User(7L, "fallback");
|
|
});
|
|
assertEquals("fallback", got.getName());
|
|
assertEquals(1, calls.get());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadBreakdownDisabledLoadsWithoutLock() {
|
|
props.getBreakdown().setEnabled(false);
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "a"));
|
|
assertEquals("a", got.getName());
|
|
verify(valueOps, never()).setIfAbsent(anyString(), anyString(), any(Duration.class));
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadInternalDecodeFailureFallsBackToReload() {
|
|
// 初始读到脏数据(当未命中降级),抢到锁后回源重建
|
|
when(valueOps.get("cache:user:1")).thenReturn("{dirty").thenReturn("{dirty");
|
|
when(valueOps.setIfAbsent(eq("cache:user:1:__lock__"), anyString(), any(Duration.class)))
|
|
.thenReturn(true);
|
|
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(1L, "fresh"));
|
|
assertEquals("fresh", got.getName());
|
|
}
|
|
|
|
// ---- #1+#2: ttl<=0 入口校验(fail-fast,不下发非法过期) ----
|
|
|
|
@Test
|
|
void setRejectsNonPositiveTtl() {
|
|
User u = new User(1L, "a");
|
|
assertThrows(IllegalArgumentException.class, () -> template.set("user:1", u, 0));
|
|
assertThrows(IllegalArgumentException.class, () -> template.set("user:1", u, -1));
|
|
verify(valueOps, never()).set(anyString(), anyString(), anyLong(), any());
|
|
}
|
|
|
|
@Test
|
|
void expireRejectsNonPositiveTtl() {
|
|
assertThrows(IllegalArgumentException.class, () -> template.expire("user:1", 0));
|
|
assertThrows(IllegalArgumentException.class, () -> template.expire("user:1", -5));
|
|
verify(redis, never()).expire(anyString(), anyLong(), any());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadRejectsNonPositiveTtl() {
|
|
props.getBreakdown().setEnabled(false); // 避免跑红时走入等待自旋
|
|
assertThrows(IllegalArgumentException.class,
|
|
() -> template.getOrLoad("user:1", User.class, 0, () -> new User(1L, "a")));
|
|
}
|
|
|
|
// ---- #3+#4: 击穿等待的中断响应与 interval 兜底 ----
|
|
|
|
@Test
|
|
void getOrLoadWaitIntervalZeroDoesNotSpinForever() {
|
|
props.getBreakdown().setWaitIntervalMs(0); // 误配 0:旧实现 waited 永不前进 → 无限循环
|
|
props.getBreakdown().setWaitTimeoutMs(50);
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
|
User got = assertTimeoutPreemptively(Duration.ofSeconds(2), () ->
|
|
template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb")));
|
|
assertEquals("fb", got.getName());
|
|
}
|
|
|
|
@Test
|
|
void getOrLoadInterruptedDuringWaitStopsSpinning() {
|
|
props.getBreakdown().setWaitIntervalMs(20);
|
|
props.getBreakdown().setWaitTimeoutMs(10000); // 大超时:旧实现中断后空转数百次到超时
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
when(valueOps.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(false);
|
|
Thread.currentThread().interrupt(); // 当前线程预置中断,getOrLoad 同步执行可感知
|
|
User got = template.getOrLoad("user:1", User.class, 60, () -> new User(7L, "fb"));
|
|
boolean wasInterrupted = Thread.interrupted(); // 读取并清除,避免污染后续测试
|
|
assertEquals("fb", got.getName());
|
|
assertTrue(wasInterrupted, "中断标志应被保留(协作式取消)");
|
|
// 中断后立即停止等待:仅首次 tryGet 读一次,而非空转数百次
|
|
verify(valueOps, atMost(2)).get("cache:user:1");
|
|
}
|
|
|
|
// ---- #5: 构造期配置 fail-fast 校验 ----
|
|
|
|
private RedisCacheEnhanceTemplate newTemplateWith(Consumer<RedisCacheProperties> mut) {
|
|
RedisCacheProperties p = new RedisCacheProperties();
|
|
mut.accept(p);
|
|
return new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, p);
|
|
}
|
|
|
|
@Test
|
|
void constructorRejectsInvalidProps() {
|
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setDefaultTtlSeconds(0)));
|
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setNullTtlSeconds(-1)));
|
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setJitterRatio(1.0)));
|
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.setJitterRatio(-0.1)));
|
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.getBreakdown().setLockTtlMs(0)));
|
|
assertThrows(IllegalArgumentException.class, () -> newTemplateWith(p -> p.getBreakdown().setWaitIntervalMs(0)));
|
|
}
|
|
|
|
@Test
|
|
void constructorAcceptsDefaults() {
|
|
new RedisCacheEnhanceTemplate(redis, keyBuilder, codec, new RedisCacheProperties());
|
|
}
|
|
|
|
// ---- #7: exists 排除空值标记(与 get 口径一致) ----
|
|
|
|
@Test
|
|
void existsTrueForRealValue() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(JSON.toJSONString(new User(1L, "a")));
|
|
assertTrue(template.exists("user:1"));
|
|
}
|
|
|
|
@Test
|
|
void existsFalseForNullSentinel() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(CacheConstant.NULL_SENTINEL);
|
|
assertFalse(template.exists("user:1"));
|
|
}
|
|
|
|
@Test
|
|
void existsFalseForMissing() {
|
|
when(valueOps.get("cache:user:1")).thenReturn(null);
|
|
assertFalse(template.exists("user:1"));
|
|
}
|
|
}
|