diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java index 2306b84c..c5507070 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalTempCleanupService.java @@ -2,8 +2,10 @@ package com.nanri.aiimage.modules.file.service; import cn.hutool.core.io.FileUtil; import com.nanri.aiimage.config.StorageProperties; +import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; 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 String TRANSIENT_PAYLOAD_DIR_NAME = "transient-payload"; + private static final String CLEANUP_FAILED_METRIC = "aiimage.temp-cleanup.failed"; private static final List RESULT_DIR_NAMES = List.of( "dedupe-result", "convert-result", @@ -32,6 +35,10 @@ public class LocalTempCleanupService { private final StorageProperties storageProperties; + /** 指标注册表(可选注入:无注册表时仅记日志,不影响清理)。 */ + @Autowired(required = false) + private MeterRegistry meterRegistry; + @Scheduled(cron = "${aiimage.storage.cleanup-cron:0 0 */6 * * *}") public void cleanupLocalTempDir() { if (!storageProperties.isCleanupEnabled()) { @@ -78,7 +85,7 @@ public class LocalTempCleanupService { deleteEmptyDirectories(child, tempDir); } } 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(); } + /** + * 清理失败处理:记录日志与失败指标,不抛异常、不重试(下一轮定时清理自然重试)。 + * 单个条目失败不影响其余条目的清理。 + */ + 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) { // 以 mtime 近似最后访问(TempFileMetadata 回退语义,与现状一致) return TempFileMetadata.lastAccessTime(file).isBefore(expireBefore); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/CleanupFailureMetricTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/CleanupFailureMetricTest.java new file mode 100644 index 00000000..fb1f2823 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/CleanupFailureMetricTest.java @@ -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); + } +}