mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 09:58:21 +00:00
fix(storage): restore multipart disk compression and make the legacy decompressor resumable
Multipart uploads have bypassed disk compression since #5169 removed the session marker as a stopgap for mid-stream GET failures. The actual root cause was never the multipart layout: the legacy DecompressReader reset its payload consumption state on every poll re-entry, so a Poll::Pending in the middle of a block payload (routine under the erasure duplex) desynchronized the block framing and surfaced as LZ4 frameType errors. This rewrites the decoder as a resumable state machine, restores the multipart session compression marker, reports logical part sizes in ListParts, and makes the rebalance migration read raw stored bytes so compressed and encrypted objects survive migration verbatim. Fixes #5957. Internal tracking: backlog#1848, backlog#1850.
This commit is contained in:
@@ -67,7 +67,10 @@ fn configured_capture_log_path(temp_dir: &str) -> Option<String> {
|
||||
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn capture_command_logs(command: &mut Command, log_path: Option<&str>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
pub(crate) fn capture_command_logs(
|
||||
command: &mut Command,
|
||||
log_path: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let Some(log_path) = log_path else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -25,6 +26,15 @@ fn generate_compressible_data(size: usize) -> Vec<u8> {
|
||||
data
|
||||
}
|
||||
|
||||
/// Deterministic 2048-byte-period binary pattern that compresses extremely well: every part
|
||||
/// yields many compressed blocks, which is exactly the shape that reproduced the mid-payload
|
||||
/// Pending truncation (rustfs/rustfs#5957).
|
||||
fn generate_high_ratio_binary_data(size: usize, seed: u8) -> Vec<u8> {
|
||||
(0..size)
|
||||
.map(|i| ((i as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) >> 3) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_part_files(temp_dir: &str, bucket: &str, object_key: &str) -> Vec<PathBuf> {
|
||||
let bucket_path = PathBuf::from(temp_dir).join(bucket);
|
||||
let mut part_files = Vec::new();
|
||||
@@ -55,7 +65,11 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
|
||||
env.cleanup_existing_processes().await?;
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let process = Command::new(&binary_path)
|
||||
// Route the child's stdout/stderr through the shared RUSTFS_E2E_LOG_DIR
|
||||
// capture (survives the temp-dir cleanup on Drop and is uploaded as a CI
|
||||
// artifact); without the env var the child inherits stdio as before.
|
||||
let mut command = Command::new(&binary_path);
|
||||
command
|
||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||
.env("RUSTFS_COMPRESSION_ENABLED", "true")
|
||||
.args([
|
||||
@@ -66,8 +80,9 @@ async fn start_rustfs_with_compression(env: &mut RustFSTestEnvironment) -> Resul
|
||||
"--secret-key",
|
||||
&env.secret_key,
|
||||
&env.temp_dir,
|
||||
])
|
||||
.spawn()?;
|
||||
]);
|
||||
crate::common::capture_command_logs(&mut command, env.capture_log_path.as_deref())?;
|
||||
let process = command.spawn()?;
|
||||
|
||||
env.process = Some(process);
|
||||
|
||||
@@ -154,3 +169,646 @@ async fn test_compression_roundtrip() -> Result<(), Box<dyn std::error::Error +
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MULTIPART_COMPRESSION_BUCKET: &str = "compression-multipart-bucket";
|
||||
const MPU_PART1_SIZE: usize = 5 * 1024 * 1024;
|
||||
const MPU_PART2_SIZE: usize = 1024 * 1024;
|
||||
|
||||
async fn multipart_upload(
|
||||
client: &aws_sdk_s3::Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
parts: &[&[u8]],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
||||
|
||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let part_number = (i + 1) as i32;
|
||||
let upload = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(part.to_vec()))
|
||||
.send()
|
||||
.await?;
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(upload.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_range(
|
||||
client: &aws_sdk_s3::Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
range: &str,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let response = client.get_object().bucket(bucket).key(key).range(range).send().await?;
|
||||
Ok(response.body.collect().await?.into_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// Multipart disk compression roundtrip: parts are written as independent
|
||||
/// compressed streams and every GET shape must reassemble the original bytes
|
||||
/// (rustfs/rustfs#5957: multipart uploads previously bypassed disk compression
|
||||
/// entirely).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_compression_multipart_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting multipart compression roundtrip test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
start_rustfs_with_compression(&mut env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
|
||||
|
||||
let object_key = "multipart-compressible.txt";
|
||||
let part1 = generate_compressible_data(MPU_PART1_SIZE);
|
||||
let part2 = generate_compressible_data(MPU_PART2_SIZE);
|
||||
let mut original_data = part1.clone();
|
||||
original_data.extend_from_slice(&part2);
|
||||
let total_size = original_data.len();
|
||||
|
||||
multipart_upload(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &[&part1, &part2]).await?;
|
||||
|
||||
let head_response = client
|
||||
.head_object()
|
||||
.bucket(MULTIPART_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head_response.content_length().unwrap_or(0) as usize,
|
||||
total_size,
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MULTIPART_COMPRESSION_BUCKET, object_key);
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
||||
);
|
||||
info!("Multipart physical storage size: {total_physical_size} bytes (compressed from {total_size} bytes)");
|
||||
|
||||
// Full GET must reassemble both independently compressed parts.
|
||||
let get_response = client
|
||||
.get_object()
|
||||
.bucket(MULTIPART_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded.len(), total_size);
|
||||
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
|
||||
|
||||
// Range fully inside part 1.
|
||||
let range_inside_part1 = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, "bytes=1024-999423").await?;
|
||||
assert_eq!(&range_inside_part1[..], &original_data[1024..999424], "part-1 range mismatch");
|
||||
|
||||
// Range crossing the part boundary.
|
||||
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
|
||||
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
|
||||
let range_crossing = fetch_range(
|
||||
&client,
|
||||
MULTIPART_COMPRESSION_BUCKET,
|
||||
object_key,
|
||||
&format!("bytes={boundary_start}-{boundary_end}"),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
&range_crossing[..],
|
||||
&original_data[boundary_start..boundary_end + 1],
|
||||
"boundary-crossing range mismatch"
|
||||
);
|
||||
|
||||
// Range fully inside part 2.
|
||||
let part2_start = MPU_PART1_SIZE + 4096;
|
||||
let part2_end = MPU_PART1_SIZE + 256 * 1024 - 1;
|
||||
let range_inside_part2 = fetch_range(
|
||||
&client,
|
||||
MULTIPART_COMPRESSION_BUCKET,
|
||||
object_key,
|
||||
&format!("bytes={part2_start}-{part2_end}"),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
&range_inside_part2[..],
|
||||
&original_data[part2_start..part2_end + 1],
|
||||
"part-2 range mismatch"
|
||||
);
|
||||
|
||||
// Suffix range (last 128 KiB, entirely in part 2).
|
||||
let suffix_len = 128 * 1024;
|
||||
let suffix = fetch_range(&client, MULTIPART_COMPRESSION_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
|
||||
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
|
||||
|
||||
// partNumber GETs must return each original part.
|
||||
for (part_number, expected) in [(1, &part1), (2, &part2)] {
|
||||
let response = client
|
||||
.get_object()
|
||||
.bucket(MULTIPART_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.part_number(part_number)
|
||||
.send()
|
||||
.await?;
|
||||
let body = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
|
||||
}
|
||||
|
||||
info!("Multipart compression roundtrip test passed");
|
||||
env.delete_test_bucket(MULTIPART_COMPRESSION_BUCKET).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MPU_HIGH_RATIO_BUCKET: &str = "compression-mpu-high-ratio-bucket";
|
||||
|
||||
/// High-ratio binary multipart payload: the object key is on the compression allow-list, so the
|
||||
/// disk-compression path runs and each part is stored as many compressed blocks — the shape that
|
||||
/// reproduced the mid-payload Pending truncation (rustfs/rustfs#5957). Every GET shape must return
|
||||
/// the exact original bytes, and the stored size must show the data really was compressed.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_compression_multipart_high_ratio_binary_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting multipart high-ratio binary compression roundtrip test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
start_rustfs_with_compression(&mut env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
|
||||
|
||||
let object_key = "multipart-high-ratio.txt";
|
||||
let part1 = generate_high_ratio_binary_data(MPU_PART1_SIZE, 7);
|
||||
let part2 = generate_high_ratio_binary_data(MPU_PART2_SIZE, 61);
|
||||
let mut original_data = part1.clone();
|
||||
original_data.extend_from_slice(&part2);
|
||||
let total_size = original_data.len();
|
||||
|
||||
multipart_upload(&client, MPU_HIGH_RATIO_BUCKET, object_key, &[&part1, &part2]).await?;
|
||||
|
||||
let head_response = client
|
||||
.head_object()
|
||||
.bucket(MPU_HIGH_RATIO_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head_response.content_length().unwrap_or(0) as usize,
|
||||
total_size,
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
// This pattern compresses to roughly 1/50 of its logical size, so a comfortably loose 2x
|
||||
// margin still proves the parts were stored compressed rather than raw or double-encoded.
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_HIGH_RATIO_BUCKET, object_key);
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
assert!(
|
||||
total_physical_size < (total_size as u64) / 2,
|
||||
"Physical size {total_physical_size} should be far below the logical size {total_size} for high-ratio data"
|
||||
);
|
||||
info!("High-ratio multipart physical storage size: {total_physical_size} bytes (logical {total_size} bytes)");
|
||||
|
||||
info!("step: full GET");
|
||||
let get_response = client
|
||||
.get_object()
|
||||
.bucket(MPU_HIGH_RATIO_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded.len(), total_size);
|
||||
assert_eq!(&downloaded[..], &original_data[..], "full GET data mismatch");
|
||||
|
||||
// Range crossing the part boundary.
|
||||
info!("step: boundary range GET");
|
||||
let boundary_start = MPU_PART1_SIZE - 128 * 1024;
|
||||
let boundary_end = MPU_PART1_SIZE + 128 * 1024 - 1;
|
||||
let range_crossing = fetch_range(
|
||||
&client,
|
||||
MPU_HIGH_RATIO_BUCKET,
|
||||
object_key,
|
||||
&format!("bytes={boundary_start}-{boundary_end}"),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
&range_crossing[..],
|
||||
&original_data[boundary_start..boundary_end + 1],
|
||||
"boundary-crossing range mismatch"
|
||||
);
|
||||
|
||||
// partNumber GET for the trailing part.
|
||||
info!("step: partNumber GET");
|
||||
let part2_response = client
|
||||
.get_object()
|
||||
.bucket(MPU_HIGH_RATIO_BUCKET)
|
||||
.key(object_key)
|
||||
.part_number(2)
|
||||
.send()
|
||||
.await?;
|
||||
let part2_body = part2_response.body.collect().await?.into_bytes();
|
||||
assert_eq!(&part2_body[..], &part2[..], "partNumber=2 GET mismatch");
|
||||
|
||||
info!("Multipart high-ratio binary compression roundtrip test passed");
|
||||
env.delete_test_bucket(MPU_HIGH_RATIO_BUCKET).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MPU_COPY_COMPRESSION_BUCKET: &str = "compression-mpu-copy-bucket";
|
||||
const MPU_COPY_SOURCE_SIZE: usize = 6 * 1024 * 1024;
|
||||
const MPU_COPY_RANGE_LEN: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// UploadPartCopy feeds a part from an already stored (and already compressed) object. The copied
|
||||
/// range must be decompressed on read and re-compressed into the destination part, so the final
|
||||
/// object has to match "source prefix + uploaded tail" byte for byte.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_compression_multipart_upload_part_copy_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting multipart upload-part-copy compression roundtrip test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
start_rustfs_with_compression(&mut env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
|
||||
|
||||
// Source object: a plain PUT that goes through the single-stream compression path.
|
||||
let source_key = "copy-source.txt";
|
||||
let source_data = generate_compressible_data(MPU_COPY_SOURCE_SIZE);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(source_key)
|
||||
.body(ByteStream::from(source_data.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Destination object: part 1 copied from the source, part 2 uploaded directly.
|
||||
let target_key = "copy-target.txt";
|
||||
let part2 = generate_compressible_data(MPU_PART2_SIZE);
|
||||
let mut expected_data = source_data[..MPU_COPY_RANGE_LEN].to_vec();
|
||||
expected_data.extend_from_slice(&part2);
|
||||
let total_size = expected_data.len();
|
||||
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(target_key)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
||||
|
||||
let copy_part = client
|
||||
.upload_part_copy()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(target_key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(1)
|
||||
.copy_source(format!("{MPU_COPY_COMPRESSION_BUCKET}/{source_key}"))
|
||||
.copy_source_range(format!("bytes=0-{}", MPU_COPY_RANGE_LEN - 1))
|
||||
.send()
|
||||
.await?;
|
||||
let copy_etag = copy_part
|
||||
.copy_part_result()
|
||||
.and_then(|r| r.e_tag())
|
||||
.ok_or("missing copy part etag")?
|
||||
.to_string();
|
||||
|
||||
let uploaded_part = client
|
||||
.upload_part()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(target_key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(part2.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(target_key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(CompletedPart::builder().part_number(1).e_tag(copy_etag).build())
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.e_tag(uploaded_part.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let head_response = client
|
||||
.head_object()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(target_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head_response.content_length().unwrap_or(0) as usize,
|
||||
total_size,
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_COPY_COMPRESSION_BUCKET, target_key);
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the copied object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (copied part compression applied)"
|
||||
);
|
||||
|
||||
let get_response = client
|
||||
.get_object()
|
||||
.bucket(MPU_COPY_COMPRESSION_BUCKET)
|
||||
.key(target_key)
|
||||
.send()
|
||||
.await?;
|
||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded.len(), total_size);
|
||||
assert_eq!(&downloaded[..], &expected_data[..], "copied multipart GET data mismatch");
|
||||
|
||||
info!("Multipart upload-part-copy compression roundtrip test passed");
|
||||
env.delete_test_bucket(MPU_COPY_COMPRESSION_BUCKET).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MPU_THREE_PARTS_BUCKET: &str = "compression-mpu-three-parts-bucket";
|
||||
const MPU_THREE_PARTS_TAIL_SIZE: usize = 512 * 1024;
|
||||
|
||||
/// Three-part upload with uneven part sizes: each partNumber GET must map back to exactly one
|
||||
/// compressed part stream, and a suffix range must resolve inside the trailing part.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_compression_multipart_three_parts_part_number_gets() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting three-part multipart compression partNumber test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
start_rustfs_with_compression(&mut env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
|
||||
|
||||
let object_key = "multipart-three-parts.txt";
|
||||
let part1 = generate_compressible_data(MPU_PART1_SIZE);
|
||||
let part2 = generate_compressible_data(MPU_PART1_SIZE);
|
||||
let part3 = generate_compressible_data(MPU_THREE_PARTS_TAIL_SIZE);
|
||||
let mut original_data = part1.clone();
|
||||
original_data.extend_from_slice(&part2);
|
||||
original_data.extend_from_slice(&part3);
|
||||
let total_size = original_data.len();
|
||||
|
||||
multipart_upload(&client, MPU_THREE_PARTS_BUCKET, object_key, &[&part1, &part2, &part3]).await?;
|
||||
|
||||
let head_response = client
|
||||
.head_object()
|
||||
.bucket(MPU_THREE_PARTS_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head_response.content_length().unwrap_or(0) as usize,
|
||||
total_size,
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_THREE_PARTS_BUCKET, object_key);
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (multipart compression applied)"
|
||||
);
|
||||
|
||||
// Every partNumber GET must return exactly the bytes of the corresponding uploaded part.
|
||||
for (part_number, expected) in [(1, &part1), (2, &part2), (3, &part3)] {
|
||||
let response = client
|
||||
.get_object()
|
||||
.bucket(MPU_THREE_PARTS_BUCKET)
|
||||
.key(object_key)
|
||||
.part_number(part_number)
|
||||
.send()
|
||||
.await?;
|
||||
let body = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(&body[..], &expected[..], "partNumber={part_number} GET mismatch");
|
||||
}
|
||||
|
||||
// Suffix range (last 64 KiB) resolves inside the trailing part.
|
||||
let suffix_len = 64 * 1024;
|
||||
let suffix = fetch_range(&client, MPU_THREE_PARTS_BUCKET, object_key, &format!("bytes=-{suffix_len}")).await?;
|
||||
assert_eq!(&suffix[..], &original_data[total_size - suffix_len..], "suffix range mismatch");
|
||||
|
||||
info!("Three-part multipart compression partNumber test passed");
|
||||
env.delete_test_bucket(MPU_THREE_PARTS_BUCKET).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
|
||||
|
||||
async fn start_rustfs_with_compression_and_sse(
|
||||
env: &mut RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use base64::Engine;
|
||||
env.cleanup_existing_processes().await?;
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
// Server output goes to a file inside the per-test temp dir so a failing
|
||||
// run can be diagnosed from the child's logs.
|
||||
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
|
||||
let server_log_err = server_log.try_clone()?;
|
||||
let process = Command::new(&binary_path)
|
||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||
.env("RUSTFS_COMPRESSION_ENABLED", "true")
|
||||
.env("RUSTFS_SSE_S3_MASTER_KEY", master_key)
|
||||
.env("RUST_LOG", "rustfs=info,rustfs_ecstore=info")
|
||||
.stdout(std::process::Stdio::from(server_log))
|
||||
.stderr(std::process::Stdio::from(server_log_err))
|
||||
.args([
|
||||
"--address",
|
||||
&env.address,
|
||||
"--access-key",
|
||||
&env.access_key,
|
||||
"--secret-key",
|
||||
&env.secret_key,
|
||||
&env.temp_dir,
|
||||
])
|
||||
.spawn()?;
|
||||
|
||||
env.process = Some(process);
|
||||
|
||||
info!("Waiting for RustFS server with compression + SSE-S3 enabled on {}", env.address);
|
||||
for i in 0..30 {
|
||||
if TcpStream::connect(&env.address).await.is_ok() {
|
||||
info!("RustFS server is ready after {} attempts", i + 1);
|
||||
return Ok(());
|
||||
}
|
||||
if i == 29 {
|
||||
return Err("RustFS server failed to become ready".into());
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SSE-S3 + disk compression multipart: each part is compressed and then encrypted, and every GET
|
||||
/// shape must still return the original plaintext bytes. Physical size must shrink because the
|
||||
/// compression runs before encryption.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_compression_multipart_sse_s3_roundtrip() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
|
||||
init_logging();
|
||||
info!("Starting SSE-S3 multipart compression roundtrip test");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
start_rustfs_with_compression_and_sse(&mut env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
env.create_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
|
||||
|
||||
let object_key = "multipart-sse-compressible.txt";
|
||||
let part1 = generate_compressible_data(MPU_PART1_SIZE);
|
||||
let part2 = generate_compressible_data(MPU_PART2_SIZE);
|
||||
let mut original_data = part1.clone();
|
||||
original_data.extend_from_slice(&part2);
|
||||
let total_size = original_data.len();
|
||||
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("missing upload id")?.to_string();
|
||||
|
||||
let mut completed_parts = Vec::new();
|
||||
for (i, part) in [&part1, &part2].into_iter().enumerate() {
|
||||
let part_number = (i + 1) as i32;
|
||||
let upload = client
|
||||
.upload_part()
|
||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(upload.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let head_response = client
|
||||
.head_object()
|
||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
head_response.content_length().unwrap_or(0) as usize,
|
||||
total_size,
|
||||
"Content-Length should be the logical object size"
|
||||
);
|
||||
assert_eq!(
|
||||
head_response.server_side_encryption(),
|
||||
Some(&ServerSideEncryption::Aes256),
|
||||
"HEAD must report SSE-S3"
|
||||
);
|
||||
|
||||
let part_files = find_part_files(&env.temp_dir, MPU_SSE_COMPRESSION_BUCKET, object_key);
|
||||
assert!(!part_files.is_empty(), "expected on-disk part files for the multipart object");
|
||||
let total_physical_size: u64 = part_files.iter().filter_map(|p| fs::metadata(p).ok()).map(|m| m.len()).sum();
|
||||
assert!(
|
||||
total_physical_size < (total_size / 2) as u64,
|
||||
"Physical size {total_physical_size} should be well below original size {total_size} (compress-then-encrypt applied)"
|
||||
);
|
||||
|
||||
let get_response = client
|
||||
.get_object()
|
||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
let downloaded = get_response.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded.len(), total_size);
|
||||
assert_eq!(&downloaded[..], &original_data[..], "SSE-S3 multipart full GET data mismatch");
|
||||
|
||||
// Range crossing the part boundary must decrypt and decompress across parts.
|
||||
let boundary_start = MPU_PART1_SIZE - 64 * 1024;
|
||||
let boundary_end = MPU_PART1_SIZE + 64 * 1024 - 1;
|
||||
let range_crossing = fetch_range(
|
||||
&client,
|
||||
MPU_SSE_COMPRESSION_BUCKET,
|
||||
object_key,
|
||||
&format!("bytes={boundary_start}-{boundary_end}"),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
&range_crossing[..],
|
||||
&original_data[boundary_start..boundary_end + 1],
|
||||
"SSE-S3 boundary-crossing range mismatch"
|
||||
);
|
||||
|
||||
// partNumber GET for the trailing part.
|
||||
let part2_response = client
|
||||
.get_object()
|
||||
.bucket(MPU_SSE_COMPRESSION_BUCKET)
|
||||
.key(object_key)
|
||||
.part_number(2)
|
||||
.send()
|
||||
.await?;
|
||||
let part2_body = part2_response.body.collect().await?.into_bytes();
|
||||
assert_eq!(&part2_body[..], &part2[..], "SSE-S3 partNumber=2 GET mismatch");
|
||||
|
||||
info!("SSE-S3 multipart compression roundtrip test passed");
|
||||
env.delete_test_bucket(MPU_SSE_COMPRESSION_BUCKET).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1828,9 +1828,11 @@ async fn four_node_compressed_inline_fallback() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Multipart disk compression is live again, so a compression-enabled cluster classifies multipart objects as compressed and the roundtrip (full GET plus partNumber GET) must still return the original bytes.
|
||||
/// Reverting the multipart compression fix must fail this test.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn four_node_multipart_ignores_disk_compression_fallback() -> TestResult {
|
||||
async fn four_node_multipart_disk_compression_roundtrip() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let collector = OtlpMetricCollector::start().await?;
|
||||
@@ -1839,22 +1841,22 @@ async fn four_node_multipart_ignores_disk_compression_fallback() -> TestResult {
|
||||
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||
cluster.start().await?;
|
||||
|
||||
let bucket = "inline-multipart-compression-fallback";
|
||||
let bucket = "inline-multipart-compression-roundtrip";
|
||||
cluster.create_test_bucket(bucket).await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
let key = "multipart/compression-disabled.txt";
|
||||
let key = "multipart/compressed.txt";
|
||||
let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?;
|
||||
|
||||
assert_reader_path(
|
||||
&collector,
|
||||
&client,
|
||||
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, MULTIPART),
|
||||
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, COMPRESSED),
|
||||
)
|
||||
.await?;
|
||||
assert_part_number_reader_path(
|
||||
&collector,
|
||||
&client,
|
||||
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), MULTIPART, LEGACY_DUPLEX),
|
||||
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), COMPRESSED, LEGACY_DUPLEX),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1890,14 +1892,21 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
|
||||
ReaderPathExpectation::for_class(
|
||||
ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None),
|
||||
LEGACY_DUPLEX,
|
||||
MULTIPART,
|
||||
COMPRESSED,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_part_number_reader_path(
|
||||
&collector,
|
||||
&client,
|
||||
PartNumberReaderPathExpectation::new(bucket, multipart_key, &second_part, multipart_body.len(), MULTIPART, LEGACY_DUPLEX),
|
||||
PartNumberReaderPathExpectation::new(
|
||||
bucket,
|
||||
multipart_key,
|
||||
&second_part,
|
||||
multipart_body.len(),
|
||||
COMPRESSED,
|
||||
LEGACY_DUPLEX,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_msgpack_decode_observed(&collector, &decode_before).await?;
|
||||
@@ -2353,7 +2362,11 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
|
||||
hot_client.create_bucket().bucket(bucket).send().await?;
|
||||
put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?;
|
||||
|
||||
let key = "transition/mixed-multipart.bin";
|
||||
// `.zip` sits on the disk-compression exclusion list: this test pins
|
||||
// msgpack compat controls across ILM transition, and a compressed object
|
||||
// would classify as `compressed` instead of `remote` (and the warm-tier
|
||||
// read path does not decode compression — tracked separately).
|
||||
let key = "transition/mixed-multipart.zip";
|
||||
let (body, second_part, etag) = put_two_part_multipart(&hot_client, bucket, key).await?;
|
||||
wait_for_transition(&hot_client, bucket, key, &tier_name).await?;
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user