From f49825645e516a378219af1f328cef7c82441e07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:06:27 +0900 Subject: [PATCH 1/7] test(artifact): reject orphaned partial writes --- ...leSystemArtifactStorePartialWriteTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java diff --git a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java new file mode 100644 index 00000000..59f19b28 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java @@ -0,0 +1,51 @@ +package com.clearfolio.viewer.artifact; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that a failed multi-file artifact write does not publish orphaned + * PDF bytes without the matching metadata sidecar. + */ +class FileSystemArtifactStorePartialWriteTest { + + @TempDir + Path temporaryDirectory; + + @Test + void metadataWriteFailureRollsBackNewArtifactBytes() throws Exception { + Path root = temporaryDirectory.resolve("artifacts"); + UUID docId = UUID.randomUUID(); + FileSystemArtifactStore store = new FileSystemArtifactStore( + root, + (path, bytes) -> { + if (path.getFileName().toString().endsWith(".meta.properties")) { + throw new IOException("metadata disk full"); + } + Files.write(path, bytes); + }, + Files::readAllBytes + ); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> store.putPdf(docId, "%PDF-1.7\npartial-write".getBytes(StandardCharsets.UTF_8)) + ); + + assertEquals("failed to persist artifact for docId " + docId, error.getMessage()); + assertTrue(Files.notExists(root.resolve(docId + ".pdf"))); + assertTrue(Files.notExists(root.resolve(docId + ".meta.properties"))); + assertEquals(Optional.empty(), store.getPdf(docId)); + } +} From 74e77bcc0309edfbe93d132d098bfa9546555c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:09:47 +0900 Subject: [PATCH 2/7] test(artifact): preserve rollback failure evidence --- ...leSystemArtifactStorePartialWriteTest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java index 59f19b28..bd2bf416 100644 --- a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java +++ b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java @@ -48,4 +48,31 @@ void metadataWriteFailureRollsBackNewArtifactBytes() throws Exception { assertTrue(Files.notExists(root.resolve(docId + ".meta.properties"))); assertEquals(Optional.empty(), store.getPdf(docId)); } + + @Test + void rollbackFailureIsRetainedAsSuppressedEvidence() throws Exception { + Path root = temporaryDirectory.resolve("rollback-failure"); + UUID docId = UUID.randomUUID(); + FileSystemArtifactStore store = new FileSystemArtifactStore( + root, + (path, bytes) -> { + if (path.getFileName().toString().endsWith(".pdf")) { + Files.createDirectory(path); + Files.writeString(path.resolve("retained"), "evidence"); + return; + } + throw new IOException("metadata disk full"); + }, + Files::readAllBytes + ); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> store.putPdf(docId, "%PDF-1.7\nrollback-failure".getBytes(StandardCharsets.UTF_8)) + ); + + assertEquals("failed to persist artifact for docId " + docId, error.getMessage()); + assertEquals(1, error.getCause().getSuppressed().length); + assertTrue(Files.exists(root.resolve(docId + ".pdf").resolve("retained"))); + } } From 90ec7d4d2f86923354e15850aaa18d2488539cf7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:10:21 +0900 Subject: [PATCH 3/7] fix(artifact): roll back failed sidecar writes --- .../viewer/artifact/FileSystemArtifactStore.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java index 7e756fa7..ee5db9c6 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java @@ -70,6 +70,7 @@ public void putPdf(UUID docId, byte[] pdfBytes) { bytesWriter.write(pdfPath(docId), copy); bytesWriter.write(metadataPath(docId), metadataBytes(docId, copy)); } catch (IOException ex) { + rollbackPartialWrite(docId, ex); throw new IllegalStateException("failed to persist artifact for docId " + docId, ex); } cache.put(docId, copy); @@ -112,6 +113,15 @@ public void deletePdf(UUID docId) { } } + private void rollbackPartialWrite(UUID docId, IOException writeFailure) { + try { + Files.deleteIfExists(metadataPath(docId)); + Files.deleteIfExists(pdfPath(docId)); + } catch (IOException rollbackFailure) { + writeFailure.addSuppressed(rollbackFailure); + } + } + private Path pdfPath(UUID docId) { return rootDir.resolve(docId + PDF_SUFFIX); } From e63d8187cd956bb7085b057d2adf34bf6b7693cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:35:38 +0900 Subject: [PATCH 4/7] test(reliability): require independent artifact rollback cleanup --- ...leSystemArtifactStorePartialWriteTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java index bd2bf416..a6f59f7d 100644 --- a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java +++ b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java @@ -75,4 +75,34 @@ void rollbackFailureIsRetainedAsSuppressedEvidence() throws Exception { assertEquals(1, error.getCause().getSuppressed().length); assertTrue(Files.exists(root.resolve(docId + ".pdf").resolve("retained"))); } + + @Test + void metadataRollbackFailureStillAttemptsPdfCleanup() throws Exception { + Path root = temporaryDirectory.resolve("independent-rollback"); + UUID docId = UUID.randomUUID(); + Path pdfPath = root.resolve(docId + ".pdf"); + Path metadataPath = root.resolve(docId + ".meta.properties"); + FileSystemArtifactStore store = new FileSystemArtifactStore( + root, + (path, bytes) -> { + if (path.equals(pdfPath)) { + Files.write(path, bytes); + return; + } + Files.createDirectory(metadataPath); + Files.writeString(metadataPath.resolve("retained"), "evidence"); + throw new IOException("metadata disk full"); + }, + Files::readAllBytes + ); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> store.putPdf(docId, "%PDF-1.7\nindependent-rollback".getBytes(StandardCharsets.UTF_8)) + ); + + assertEquals(1, error.getCause().getSuppressed().length); + assertTrue(Files.exists(metadataPath.resolve("retained"))); + assertTrue(Files.notExists(pdfPath)); + } } From 6d6839b54c5b370c284c14029e728b8ef153ef4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 05:37:24 +0900 Subject: [PATCH 5/7] fix(reliability): attempt each partial artifact cleanup --- .../viewer/artifact/FileSystemArtifactStore.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java index ee5db9c6..14661842 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java @@ -114,9 +114,13 @@ public void deletePdf(UUID docId) { } private void rollbackPartialWrite(UUID docId, IOException writeFailure) { + deletePartialFile(metadataPath(docId), writeFailure); + deletePartialFile(pdfPath(docId), writeFailure); + } + + private static void deletePartialFile(Path path, IOException writeFailure) { try { - Files.deleteIfExists(metadataPath(docId)); - Files.deleteIfExists(pdfPath(docId)); + Files.deleteIfExists(path); } catch (IOException rollbackFailure) { writeFailure.addSuppressed(rollbackFailure); } From 394db4ac7d867230fe6f5026ccdc631425246d44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 22:21:17 +0900 Subject: [PATCH 6/7] test(artifact): preserve committed bytes on failed replacement --- ...leSystemArtifactStorePartialWriteTest.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java index a6f59f7d..9580d777 100644 --- a/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java +++ b/src/test/java/com/clearfolio/viewer/artifact/FileSystemArtifactStorePartialWriteTest.java @@ -1,5 +1,6 @@ package com.clearfolio.viewer.artifact; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -49,6 +50,33 @@ void metadataWriteFailureRollsBackNewArtifactBytes() throws Exception { assertEquals(Optional.empty(), store.getPdf(docId)); } + @Test + void failedReplacementPreservesPreviouslyCommittedArtifactAcrossRestart() throws Exception { + Path root = temporaryDirectory.resolve("replacement"); + UUID docId = UUID.randomUUID(); + byte[] originalPdf = "%PDF-1.7\noriginal".getBytes(StandardCharsets.UTF_8); + byte[] replacementPdf = "%PDF-1.7\nreplacement".getBytes(StandardCharsets.UTF_8); + new FileSystemArtifactStore(root).putPdf(docId, originalPdf); + + FileSystemArtifactStore failingStore = new FileSystemArtifactStore( + root, + (path, bytes) -> { + if (path.getFileName().toString().endsWith(".meta.properties")) { + throw new IOException("metadata disk full"); + } + Files.write(path, bytes); + }, + Files::readAllBytes + ); + + assertThrows(IllegalStateException.class, () -> failingStore.putPdf(docId, replacementPdf)); + + assertTrue(Files.exists(root.resolve(docId + ".pdf"))); + assertTrue(Files.exists(root.resolve(docId + ".meta.properties"))); + FileSystemArtifactStore restartedStore = new FileSystemArtifactStore(root); + assertArrayEquals(originalPdf, restartedStore.getPdf(docId).orElseThrow()); + } + @Test void rollbackFailureIsRetainedAsSuppressedEvidence() throws Exception { Path root = temporaryDirectory.resolve("rollback-failure"); From a6372a30e31312b5310247c7ccf195368a84d376 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 22:25:28 +0900 Subject: [PATCH 7/7] fix(artifact): restore committed state after failed replacement --- .../artifact/FileSystemArtifactStore.java | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java index 14661842..cdf47c9d 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/FileSystemArtifactStore.java @@ -66,11 +66,13 @@ public FileSystemArtifactStore(Path rootDir) { @Override public void putPdf(UUID docId, byte[] pdfBytes) { byte[] copy = pdfBytes.clone(); + byte[] metadata = metadataBytes(docId, copy); + ArtifactFilesSnapshot previous = snapshotExistingFiles(docId); try { bytesWriter.write(pdfPath(docId), copy); - bytesWriter.write(metadataPath(docId), metadataBytes(docId, copy)); + bytesWriter.write(metadataPath(docId), metadata); } catch (IOException ex) { - rollbackPartialWrite(docId, ex); + rollbackPartialWrite(docId, previous, ex); throw new IllegalStateException("failed to persist artifact for docId " + docId, ex); } cache.put(docId, copy); @@ -113,14 +115,37 @@ public void deletePdf(UUID docId) { } } - private void rollbackPartialWrite(UUID docId, IOException writeFailure) { - deletePartialFile(metadataPath(docId), writeFailure); - deletePartialFile(pdfPath(docId), writeFailure); + private ArtifactFilesSnapshot snapshotExistingFiles(UUID docId) { + try { + return new ArtifactFilesSnapshot( + existingFileBytes(pdfPath(docId)), + existingFileBytes(metadataPath(docId)) + ); + } catch (IOException ex) { + throw new IllegalStateException("failed to snapshot existing artifact for docId " + docId, ex); + } + } + + private static byte[] existingFileBytes(Path path) throws IOException { + return Files.exists(path) ? Files.readAllBytes(path) : null; } - private static void deletePartialFile(Path path, IOException writeFailure) { + private void rollbackPartialWrite( + UUID docId, + ArtifactFilesSnapshot previous, + IOException writeFailure + ) { + restorePartialFile(pdfPath(docId), previous.pdfBytes(), writeFailure); + restorePartialFile(metadataPath(docId), previous.metadataBytes(), writeFailure); + } + + private static void restorePartialFile(Path path, byte[] previousBytes, IOException writeFailure) { try { - Files.deleteIfExists(path); + if (previousBytes == null) { + Files.deleteIfExists(path); + } else { + Files.write(path, previousBytes); + } } catch (IOException rollbackFailure) { writeFailure.addSuppressed(rollbackFailure); } @@ -151,4 +176,7 @@ private static String sha256Hex(final byte[] bytes) { throw new IllegalStateException("SHA-256 digest unavailable", ex); } } + + private record ArtifactFilesSnapshot(byte[] pdfBytes, byte[] metadataBytes) { + } }