task-32: 将 task entity 本地缓存替换为有容量和过期回收的实现
This commit is contained in:
@@ -7,6 +7,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "aiimage.task-pressure")
|
||||
public class TaskPressureProperties {
|
||||
private long localTaskEntityCacheMillis = 3000;
|
||||
/** task entity 本地缓存容量上限,超限时按时间戳 LRU 淘汰最旧条目。 */
|
||||
private int localTaskEntityCacheCapacity = 512;
|
||||
// 本地文件缓存有效时长,超过该时长视为过期、强制回查 DB,避免陈旧 RUNNING 被复活
|
||||
private long localTaskEntityFileCacheMillis = 60000;
|
||||
private int dbSelectBatchSize = 200;
|
||||
|
||||
+28
-1
@@ -13,6 +13,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -145,7 +146,7 @@ public class ShopDataCrawlTaskCacheService {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
|
||||
putLocalCache(task.getId(), new LocalTaskEntityCacheEntry(
|
||||
now,
|
||||
objectMapper.convertValue(task, FileTaskEntity.class)
|
||||
));
|
||||
@@ -159,6 +160,23 @@ public class ShopDataCrawlTaskCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界本地缓存写入:容量达到上限时按 cachedAtMillis LRU 淘汰最旧条目,
|
||||
* 保证本地缓存内存有界。
|
||||
*/
|
||||
private void putLocalCache(Long taskId, LocalTaskEntityCacheEntry entry) {
|
||||
taskEntityLocalCache.put(taskId, entry);
|
||||
int capacity = Math.max(1, taskPressureProperties.getLocalTaskEntityCacheCapacity());
|
||||
if (taskEntityLocalCache.size() > capacity) {
|
||||
taskEntityLocalCache.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByValue(
|
||||
Comparator.comparingLong(LocalTaskEntityCacheEntry::cachedAtMillis)
|
||||
.thenComparingLong(e -> e.task() == null ? 0L : e.task().getId() == null ? 0L : e.task().getId())))
|
||||
.limit(taskEntityLocalCache.size() - capacity)
|
||||
.forEach(entryToEvict -> taskEntityLocalCache.remove(entryToEvict.getKey()));
|
||||
}
|
||||
}
|
||||
|
||||
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
@@ -178,6 +196,10 @@ public class ShopDataCrawlTaskCacheService {
|
||||
if (isLocalCacheFresh(cached, now)) {
|
||||
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
|
||||
} else {
|
||||
// 过期条目即时回收,避免本地缓存无限累积。
|
||||
if (cached != null) {
|
||||
taskEntityLocalCache.remove(taskId);
|
||||
}
|
||||
missingIds.add(taskId);
|
||||
}
|
||||
}
|
||||
@@ -221,6 +243,11 @@ public class ShopDataCrawlTaskCacheService {
|
||||
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
|
||||
}
|
||||
|
||||
/** 本地缓存当前条目数(测试与监控用)。 */
|
||||
int localCacheSize() {
|
||||
return taskEntityLocalCache.size();
|
||||
}
|
||||
|
||||
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
||||
}
|
||||
|
||||
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user