Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +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, previous, ex);
throw new IllegalStateException("failed to persist artifact for docId " + docId, ex);
}
cache.put(docId, copy);
Expand Down Expand Up @@ -112,6 +115,42 @@ public void deletePdf(UUID docId) {
}
}

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 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 {
if (previousBytes == null) {
Files.deleteIfExists(path);
} else {
Files.write(path, previousBytes);
}
} catch (IOException rollbackFailure) {
writeFailure.addSuppressed(rollbackFailure);
}
}

private Path pdfPath(UUID docId) {
return rootDir.resolve(docId + PDF_SUFFIX);
}
Expand All @@ -137,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) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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;

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));
}

@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");
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")));
}

@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));
}
}
Loading