task-32: 将 task entity 本地缓存替换为有容量和过期回收的实现

This commit is contained in:
2026-08-29 20:11:16 +08:00
parent 5f7457a07b
commit 2cd5e07248
3 changed files with 221 additions and 1 deletions
@@ -0,0 +1,191 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.TaskPressureProperties;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
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 java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 32:将 task entity 本地缓存替换为有容量和过期回收的实现。
* 原 taskEntityLocalCache 是无界 ConcurrentHashMap,只随 deleteTaskCache 清理;
* 实现改为有容量上限(localTaskEntityCacheCapacity,默认 512)的本地缓存:
* 插入时按时间戳 LRU 淘汰最旧条目,读取时回收过期条目,保证内存有界。
*/
class ShopDataCrawlTaskCacheServiceTest {
@Mock private StringRedisTemplate stringRedisTemplate;
@Mock private TaskScopePayloadStorageService taskScopePayloadStorageService;
@Mock private ValueOperations<String, String> valueOperations;
private TaskPressureProperties properties;
private ObjectMapper objectMapper;
private ShopDataCrawlTaskCacheService cacheService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
properties = new TaskPressureProperties();
properties.setLocalTaskEntityCacheMillis(3000);
properties.setLocalTaskEntityCacheCapacity(4);
objectMapper = new ObjectMapper().findAndRegisterModules();
cacheService = new ShopDataCrawlTaskCacheService(
stringRedisTemplate,
objectMapper,
properties,
taskScopePayloadStorageService);
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(valueOperations.multiGet(any())).thenReturn(List.of());
lenient().when(valueOperations.get(anyString())).thenReturn(null);
}
@Test
void test_task_032_cache_normal_default_path() {
// 正常路径:缓存保存后立即可读,命中返回相同内容且不回落 Redis/DB。
FileTaskEntity task = task(101L, "RUNNING");
cacheService.saveTaskCache(task);
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(101L));
assertEquals(1, cached.size());
assertEquals("RUNNING", cached.get(101L).getStatus());
verify(valueOperations, never()).multiGet(any());
assertEquals(1, cacheService.localCacheSize());
}
@Test
void test_task_032_cache_normal_multiple_items() {
// 批量场景:多个任务缓存互不丢失,批量读取全部命中,顺序稳定。
for (long id = 201L; id <= 206L; id++) {
cacheService.saveTaskCache(task(id, "RUNNING"));
}
// 容量 4,保存 6 个后本地只保留最近 4 个(按时间戳淘汰最旧)。
assertEquals(4, cacheService.localCacheSize());
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(205L, 206L, 203L));
assertEquals(3, cached.size(), "仍在本地缓存的条目全部命中");
assertEquals("RUNNING", cached.get(205L).getStatus());
assertEquals("RUNNING", cached.get(206L).getStatus());
assertEquals("RUNNING", cached.get(203L).getStatus());
// 被淘汰的最旧条目(201、202)不产生本地命中。
assertTrue(cacheService.getTaskCacheBatch(List.of(201L)).isEmpty());
}
@Test
void test_task_032_cache_normal_repeated_operation_is_idempotent() {
// 幂等:重复保存同一任务不放大缓存条目,内容为最新。
FileTaskEntity task = task(301L, "RUNNING");
cacheService.saveTaskCache(task);
FileTaskEntity updated = task(301L, "SUCCESS");
cacheService.saveTaskCache(updated);
FileTaskEntity again = task(301L, "SUCCESS");
cacheService.saveTaskCache(again);
assertEquals(1, cacheService.localCacheSize(), "同一任务重复保存只占一条");
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(301L));
assertEquals("SUCCESS", cached.get(301L).getStatus(), "内容为最新");
}
@Test
void test_task_032_cache_boundary_empty_input() {
// 空输入:空列表/空任务安全返回,不创建缓存条目、不访问 Redis。
assertTrue(cacheService.getTaskCacheBatch(List.of()).isEmpty());
cacheService.saveTaskCache(null);
cacheService.saveTaskCache(taskWithoutId());
assertEquals(0, cacheService.localCacheSize());
verify(stringRedisTemplate, never()).opsForValue();
}
@Test
void test_task_032_cache_boundary_single_item() {
// 单元素:单任务缓存命中,不依赖批量路径;删除后条目释放。
FileTaskEntity task = task(501L, "RUNNING");
cacheService.saveTaskCache(task);
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(501L));
assertEquals(1, cached.size());
cacheService.deleteTaskCache(501L);
assertEquals(0, cacheService.localCacheSize(), "删除释放本地条目");
assertTrue(cacheService.getTaskCacheBatch(List.of(501L)).isEmpty());
}
@Test
void test_task_032_cache_boundary_limit_and_overflow() {
// 上限/超限:容量 4 保存 100 个任务,本地条目不超过容量,最近条目仍在。
for (long id = 601L; id <= 700L; id++) {
cacheService.saveTaskCache(task(id, "RUNNING"));
}
assertEquals(4, cacheService.localCacheSize(), "超量保存后本地条目不超过容量上限");
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(697L, 698L, 699L, 700L));
assertEquals(4, cached.size(), "最近保存的条目全部命中");
}
@Test
void test_task_032_cache_invalid_input_rejected() {
// 非法参数:非正 taskId 的批量读取/删除安全返回,不访问 Redis 或存储。
assertTrue(cacheService.getTaskCacheBatch(List.of(0L, -5L)).isEmpty());
cacheService.deleteTaskCache(null);
cacheService.deleteTaskCache(0L);
verify(taskScopePayloadStorageService, never()).deleteTaskScopePayloads(any(), any());
}
@Test
void test_task_032_cache_dependency_failure_releases_resources() {
// 依赖失败:Redis 异常时本地缓存降级可用(不抛异常),条目可回收;
// 删除时 Redis 异常不影响本地条目释放。
FileTaskEntity task = task(801L, "RUNNING");
cacheService.saveTaskCache(task);
assertEquals(1, cacheService.localCacheSize());
Map<Long, FileTaskEntity> cached = cacheService.getTaskCacheBatch(List.of(801L));
assertEquals(1, cached.size(), "本地命中不回落 Redis");
verify(valueOperations, never()).get(anyString());
// 本地条目被淘汰后读取回落 Redis,Redis 异常时降级返回空而不抛错。
cacheService.deleteTaskCache(801L);
lenient().when(valueOperations.get(anyString())).thenThrow(new RuntimeException("redis down"));
assertTrue(cacheService.getTaskCacheBatch(List.of(801L)).isEmpty(), "Redis 异常降级为空,不抛异常");
cacheService.deleteTaskCache(801L);
assertEquals(0, cacheService.localCacheSize(), "删除仍释放本地条目");
verify(taskScopePayloadStorageService, times(2)).deleteTaskScopePayloads(eq(801L), any());
}
private FileTaskEntity task(long id, String status) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType("SHOP_DATA_CRAWL");
task.setStatus(status);
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
return task;
}
private FileTaskEntity taskWithoutId() {
FileTaskEntity task = new FileTaskEntity();
task.setModuleType("SHOP_DATA_CRAWL");
task.setStatus("RUNNING");
return task;
}
}