From 3b34ec3bef1278756c560ba2268ce80b298712c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Wed, 2 Sep 2026 06:29:59 +0800 Subject: [PATCH] =?UTF-8?q?task-152:=20=E6=B8=85=E7=90=86=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=90=9E=E5=BC=82=E5=B8=B8=E8=AE=B0=E6=8C=87=E6=A0=87?= =?UTF-8?q?=EF=BC=88aiimage.temp-cleanup.failed=20=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=E3=80=81=E6=97=A5=E5=BF=97+=E6=8C=87=E6=A0=87=E5=9D=87?= =?UTF-8?q?=E4=B8=8D=E9=98=BB=E6=96=AD=E4=B8=BB=E6=B5=81=E7=A8=8B=E3=80=81?= =?UTF-8?q?=E6=8C=89=E8=B7=AF=E5=BE=84=E9=9A=94=E7=A6=BB=E3=80=81=E6=97=A0?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E5=BE=AA=E7=8E=AF=EF=BC=89+=208=20=E6=9D=A1?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/LocalTempCleanupService.java | 26 +++- .../service/CleanupFailureMetricTest.java | 119 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/file/service/CleanupFailureMetricTest.java 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); + } +}