Add cluster multipart uploads and listings

This commit is contained in:
admin committed 2026-10-10 08:48:15 +02:00
1 parent 11bfe71124
commit 00d2af2fb0
12 files changed
+660 -38

No files matched your search

@@ -3,7 +3,10 @@ package cloud.lunarsky.store;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
public final class ClusterIntegrationTest {
@@ -62,6 +65,60 @@ public final class ClusterIntegrationTest {
}
System.out.println("Cluster degraded test passed");
}
case "multipart-stage" -> {
String key = "cluster-test/multipart";
String id = store.create(bucket, key, "text/plain");
byte[] first = "hello ".getBytes(StandardCharsets.UTF_8);
byte[] second = "world".getBytes(StandardCharsets.UTF_8);
putPart(store, id, bucket, key, 1, "old".getBytes(StandardCharsets.UTF_8));
putPart(store, id, bucket, key, 1, first);
putPart(store, id, bucket, key, 2, second);
require(store.activeUploads() == 1, "Upload was not retained");
require(store.stagedBytes() == first.length + second.length, "Replaced part was counted twice");
require(store.listUploads(bucket, key).size() == 1, "Upload listing missed the staged upload");
var firstPage = store.listParts(id, bucket, key, 0, 1);
require(firstPage.truncated() && firstPage.parts().size() == 1 && firstPage.nextMarker() == 1,
"Part listing did not paginate");
require(store.listParts(id, bucket, key, 1, 1).parts().getFirst().number() == 2,
"Part marker skipped the second part");
try {
store.open(bucket, key);
throw new AssertionError("Incomplete upload became visible");
} catch (StoreException error) { require(error.status == 404, "Wrong incomplete-upload status"); }
System.out.println("Cluster multipart parts staged and listed");
}
case "multipart-complete" -> {
String key = "cluster-test/multipart";
var uploads = store.listUploads(bucket, key);
require(uploads.size() == 1, "Upload did not survive gateway restart");
String id = uploads.getFirst().id();
var listed = store.listParts(id, bucket, key, 0, 1000).parts();
require(listed.size() == 2, "Staged parts were lost");
try {
store.complete(id, bucket, key, List.of(new MultipartStorage.Part(1, "0".repeat(32))));
throw new AssertionError("Wrong part ETag was accepted");
} catch (StoreException error) { require(error.status == 400, "Wrong ETag rejection status"); }
var completed = store.complete(id, bucket, key, List.of(
new MultipartStorage.Part(1, listed.get(0).etag()),
new MultipartStorage.Part(2, listed.get(1).etag())));
byte[] expected = "hello world".getBytes(StandardCharsets.UTF_8);
require(completed.length() == expected.length && completed.contentType().equals("text/plain"),
"Completed object metadata is wrong");
MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(HexFormat.of().parseHex(listed.get(0).etag()));
digest.update(HexFormat.of().parseHex(listed.get(1).etag()));
require(completed.etag().equals(HexFormat.of().formatHex(digest.digest()) + "-2"),
"Multipart ETag is wrong");
try (var opened = store.open(bucket, key)) {
require(Arrays.equals(opened.stream().readAllBytes(), expected), "Completed multipart body is wrong");
}
require(store.activeUploads() == 0 && store.stagedBytes() == 0, "Completed parts still count as staged");
String aborted = store.create(bucket, "cluster-test/aborted", "text/plain");
putPart(store, aborted, bucket, "cluster-test/aborted", 1, expected);
store.abort(aborted, bucket, "cluster-test/aborted");
require(store.activeUploads() == 0 && store.stagedBytes() == 0, "Aborted parts still count as staged");
System.out.println("Cluster multipart completion survived restart and node loss");
}
case "quorum-lost" -> {
require(!store.ready(), "One available node must not be ready");
try {
@@ -151,6 +208,11 @@ public final class ClusterIntegrationTest {
store.put(bucket, key, new ByteArrayInputStream(data), data.length, SigV4.hex(SigV4.hash(data)),
null, createOnly, "application/octet-stream");
}
private static void putPart(ClusterStore store, String id, String bucket, String key, int number, byte[] data)
throws Exception {
store.putPart(id, bucket, key, number, new ByteArrayInputStream(data), data.length,
SigV4.hex(SigV4.hash(data)), null);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
+12
View File
@@ -144,6 +144,16 @@ public final class HttpTest {
"?partNumber=2&uploadId=" + upload), "PUT", second, Map.of()), HttpResponse.BodyHandlers.ofByteArray());
status(200, partOne);
status(200, partTwo);
var parts = client.send(signedUri(URI.create(base + "/objects/" + movie +
"?uploadId=" + upload + "&max-parts=1"), "GET", new byte[0], Map.of()),
HttpResponse.BodyHandlers.ofString());
if (parts.statusCode() != 200 || !parts.body().contains("<IsTruncated>true</IsTruncated>") ||
!parts.body().contains("<PartNumber>1</PartNumber>"))
throw new AssertionError("Multipart part listing failed: " + parts.body());
var uploads = client.send(signedUri(URI.create(base + "/objects?uploads&prefix=folder%2F"),
"GET", new byte[0], Map.of()), HttpResponse.BodyHandlers.ofString());
if (uploads.statusCode() != 200 || !uploads.body().contains("<UploadId>" + upload + "</UploadId>"))
throw new AssertionError("Multipart upload listing failed: " + uploads.body());
String completion = "<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>" +
partOne.headers().firstValue("etag").orElseThrow() +
"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>" +
@@ -156,6 +166,8 @@ public final class HttpTest {
if (!"hello world".equals(new String(assembled.body(), StandardCharsets.UTF_8)) ||
!"video/mp4".equals(assembled.headers().firstValue("content-type").orElse("")))
throw new AssertionError("Completed multipart object mismatch");
status(404, client.send(signedUri(URI.create(base + "/objects/" + movie + "?uploadId=" + upload),
"GET", new byte[0], Map.of()), HttpResponse.BodyHandlers.ofByteArray()));
var abandoned = client.send(signedUri(URI.create(base + "/objects/abandoned?uploads="),
"POST", new byte[0], Map.of()), HttpResponse.BodyHandlers.ofString());
String abandonedId = abandoned.body().split("<UploadId>")[1].split("</UploadId>")[0];