task-152: 清理失败吞异常记指标(aiimage.temp-cleanup.failed 计数、日志+指标均不阻断主流程、按路径隔离、无重试循环)+ 8 条测试
This commit is contained in:
+25
-1
@@ -2,8 +2,10 @@ package com.nanri.aiimage.modules.file.service;
|
|||||||
|
|
||||||
import cn.hutool.core.io.FileUtil;
|
import cn.hutool.core.io.FileUtil;
|
||||||
import com.nanri.aiimage.config.StorageProperties;
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ public class LocalTempCleanupService {
|
|||||||
|
|
||||||
private static final Pattern ROOT_TEMP_FILE_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}(\\.[^.]+)?$");
|
private static final Pattern ROOT_TEMP_FILE_PATTERN = Pattern.compile("^[a-fA-F0-9]{32}(\\.[^.]+)?$");
|
||||||
private static final String TRANSIENT_PAYLOAD_DIR_NAME = "transient-payload";
|
private static final String TRANSIENT_PAYLOAD_DIR_NAME = "transient-payload";
|
||||||
|
private static final String CLEANUP_FAILED_METRIC = "aiimage.temp-cleanup.failed";
|
||||||
private static final List<String> RESULT_DIR_NAMES = List.of(
|
private static final List<String> RESULT_DIR_NAMES = List.of(
|
||||||
"dedupe-result",
|
"dedupe-result",
|
||||||
"convert-result",
|
"convert-result",
|
||||||
@@ -32,6 +35,10 @@ public class LocalTempCleanupService {
|
|||||||
|
|
||||||
private final StorageProperties storageProperties;
|
private final StorageProperties storageProperties;
|
||||||
|
|
||||||
|
/** 指标注册表(可选注入:无注册表时仅记日志,不影响清理)。 */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private MeterRegistry meterRegistry;
|
||||||
|
|
||||||
@Scheduled(cron = "${aiimage.storage.cleanup-cron:0 0 */6 * * *}")
|
@Scheduled(cron = "${aiimage.storage.cleanup-cron:0 0 */6 * * *}")
|
||||||
public void cleanupLocalTempDir() {
|
public void cleanupLocalTempDir() {
|
||||||
if (!storageProperties.isCleanupEnabled()) {
|
if (!storageProperties.isCleanupEnabled()) {
|
||||||
@@ -78,7 +85,7 @@ public class LocalTempCleanupService {
|
|||||||
deleteEmptyDirectories(child, tempDir);
|
deleteEmptyDirectories(child, tempDir);
|
||||||
}
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("local temp cleanup failed: path={}", child.getAbsolutePath(), ex);
|
handleCleanupFailure(child, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +132,23 @@ public class LocalTempCleanupService {
|
|||||||
return ROOT_TEMP_FILE_PATTERN.matcher(file.getName()).matches();
|
return ROOT_TEMP_FILE_PATTERN.matcher(file.getName()).matches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理失败处理:记录日志与失败指标,不抛异常、不重试(下一轮定时清理自然重试)。
|
||||||
|
* 单个条目失败不影响其余条目的清理。
|
||||||
|
*/
|
||||||
|
void handleCleanupFailure(File child, Exception ex) {
|
||||||
|
log.warn("local temp cleanup failed: path={}", child == null ? null : child.getAbsolutePath(), ex);
|
||||||
|
try {
|
||||||
|
if (meterRegistry != null && child != null) {
|
||||||
|
meterRegistry.counter(CLEANUP_FAILED_METRIC, "path", child.getName()).increment();
|
||||||
|
}
|
||||||
|
} catch (Exception metricEx) {
|
||||||
|
// 指标记录失败也不阻断清理主流程
|
||||||
|
log.warn("local temp cleanup metric record failed path={}",
|
||||||
|
child == null ? null : child.getName(), metricEx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isExpired(File file, Instant expireBefore) {
|
private boolean isExpired(File file, Instant expireBefore) {
|
||||||
// 以 mtime 近似最后访问(TempFileMetadata 回退语义,与现状一致)
|
// 以 mtime 近似最后访问(TempFileMetadata 回退语义,与现状一致)
|
||||||
return TempFileMetadata.lastAccessTime(file).isBefore(expireBefore);
|
return TempFileMetadata.lastAccessTime(file).isBefore(expireBefore);
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package com.nanri.aiimage.modules.file.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
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.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* task-152:清理失败吞异常记指标契约(plan 09)。
|
||||||
|
* 清理失败:记录日志+指标(清理失败计数)、不抛异常、不自动重试成循环;
|
||||||
|
* 单个条目失败不影响其他条目(隔离)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CleanupFailureMetricTest {
|
||||||
|
|
||||||
|
@Mock private StorageProperties storageProperties;
|
||||||
|
@Mock private MeterRegistry meterRegistry;
|
||||||
|
|
||||||
|
private LocalTempCleanupService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new LocalTempCleanupService(storageProperties);
|
||||||
|
ReflectionTestUtils.setField(service, "meterRegistry", meterRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failureIsLoggedWithoutThrowing() {
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("boom"));
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/b.tmp"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failureRecordsMetric() {
|
||||||
|
Counter counter = mock(Counter.class);
|
||||||
|
when(meterRegistry.counter(anyString(), anyString(), anyString())).thenReturn(counter);
|
||||||
|
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("boom"));
|
||||||
|
|
||||||
|
verify(meterRegistry).counter(eq("aiimage.temp-cleanup.failed"), eq("path"), eq("a.tmp"));
|
||||||
|
verify(counter).increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failureDoesNotThrowToCaller() {
|
||||||
|
Counter counter = mock(Counter.class);
|
||||||
|
when(meterRegistry.counter(anyString(), anyString(), anyString())).thenThrow(
|
||||||
|
new RuntimeException("metrics down"));
|
||||||
|
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("boom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failureMetricNotRepeatedInLoop() {
|
||||||
|
Counter counter = mock(Counter.class);
|
||||||
|
when(meterRegistry.counter(anyString(), anyString(), anyString())).thenReturn(counter);
|
||||||
|
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("boom"));
|
||||||
|
|
||||||
|
verify(counter, times(1)).increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nextRoundCanRetryIndependently() {
|
||||||
|
Counter counter = mock(Counter.class);
|
||||||
|
when(meterRegistry.counter(anyString(), anyString(), anyString())).thenReturn(counter);
|
||||||
|
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("boom"));
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("boom again"));
|
||||||
|
|
||||||
|
verify(counter, times(2)).increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void partialFailureIsIsolatedPerPath() {
|
||||||
|
Counter counterA = mock(Counter.class);
|
||||||
|
Counter counterB = mock(Counter.class);
|
||||||
|
when(meterRegistry.counter(anyString(), eq("path"), eq("a.tmp"))).thenReturn(counterA);
|
||||||
|
when(meterRegistry.counter(anyString(), eq("path"), eq("b.tmp"))).thenReturn(counterB);
|
||||||
|
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/a.tmp"), new RuntimeException("x"));
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/b.tmp"), new RuntimeException("y"));
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/b.tmp"), new RuntimeException("y2"));
|
||||||
|
|
||||||
|
verify(counterA, times(1)).increment();
|
||||||
|
verify(counterB, times(2)).increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricTaggedByFileName() {
|
||||||
|
Counter counter = mock(Counter.class);
|
||||||
|
when(meterRegistry.counter(anyString(), anyString(), anyString())).thenReturn(counter);
|
||||||
|
|
||||||
|
service.handleCleanupFailure(new File("target/tmp/result/x.xlsx"), new RuntimeException("boom"));
|
||||||
|
|
||||||
|
verify(meterRegistry).counter(eq("aiimage.temp-cleanup.failed"), eq("path"), eq("x.xlsx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullChildIsSafe() {
|
||||||
|
service.handleCleanupFailure(null, new RuntimeException("boom"));
|
||||||
|
service.handleCleanupFailure(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user