// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Four-node EC regression gate for inline storage and the inline GET reader. //! //! The storage decision is based on shard bytes (256 KiB / 32 KiB objects for //! the default EC 2+2 geometry), while the GET fast path has its own object-size //! limits (128 KiB / 16 KiB). A local OTLP/HTTP collector observes the existing //! reader-path counter without adding a scrape endpoint or production logging. //! One S3 GET can select readers on multiple EC nodes, so the counter tracks //! distributed reader selection rather than HTTP request count. use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, local_http_client}; use aws_sdk_s3::Client; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::types::{ BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration, }; use base64::Engine; use bytes::Bytes; use flate2::read::GzDecoder; use http::header::{CONTENT_ENCODING, HOST}; use http::{Method, Request, Response, StatusCode}; use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use hyper::service::service_fn; use hyper_util::rt::TokioIo; use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest; use opentelemetry_proto::tonic::common::v1::{KeyValue, any_value::Value as AnyValue}; use opentelemetry_proto::tonic::metrics::v1::{ Metric, NumberDataPoint, ResourceMetrics, ScopeMetrics, Sum, metric, number_data_point, }; use prost::Message; use rustfs_signer::constants::UNSIGNED_PAYLOAD; use rustfs_signer::sign_v4; use s3s::Body; use serial_test::serial; use std::collections::BTreeMap; use std::convert::Infallible; use std::error::Error; use std::io::Read; use std::path::Path; use std::sync::Arc; use std::time::Duration; use tokio::net::TcpListener; use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::{Instant, sleep}; use uuid::Uuid; use walkdir::WalkDir; type TestResult = Result>; type MetricPointVersions = BTreeMap; type MetricValues = Arc>>; const KIB: usize = 1024; const READER_PATH_COUNTER: &str = "rustfs_io_get_object_reader_path_by_size_total"; const MSGPACK_JSON_DECODE_COUNTER: &str = "rustfs_system_network_internode_msgpack_json_decode_total"; const MSGPACK_JSON_FALLBACK_COUNTER: &str = "rustfs_system_network_internode_msgpack_json_fallback_total"; const MSGPACK_JSON_DECODE_ERROR_COUNTER: &str = "rustfs_system_network_internode_msgpack_json_decode_error_total"; const DIRECTION_LABEL: &str = "direction"; const MESSAGE_LABEL: &str = "message"; const CODEC_LABEL: &str = "codec"; const INLINE_DIRECT: &str = "inline_direct"; const LEGACY_DUPLEX: &str = "legacy_duplex"; const EMPTY: &str = "empty"; const REMOTE_TRANSITION: &str = "remote_transition"; const PLAIN_SINGLE_PART: &str = "plain_single_part"; const MULTIPART: &str = "multipart"; const ENCRYPTED: &str = "encrypted"; const COMPRESSED: &str = "compressed"; const RANGE: &str = "range"; const REMOTE: &str = "remote"; const FALLBACK_REQUEST_DIRECTION: &str = "request"; const FALLBACK_RESPONSE_DIRECTION: &str = "response"; const MSGPACK_CODEC_MSGPACK: &str = "msgpack"; const MSGPACK_CODEC_JSON: &str = "json"; const MPU_PART_1_SIZE: usize = 5 * 1024 * 1024; const MPU_PART_2_SIZE: usize = 16 * KIB; const TIER_BUCKET: &str = "inline-fallback-cold-tier"; const TIER_PREFIX: &str = "tiered"; const MSGPACK_FALLBACK_CONTROL_SERIES: [(&str, &str); 4] = [ (FALLBACK_REQUEST_DIRECTION, "ReadMultipleReq"), (FALLBACK_RESPONSE_DIRECTION, "ReadMultipleResp"), (FALLBACK_REQUEST_DIRECTION, "BatchReadVersionReq"), (FALLBACK_RESPONSE_DIRECTION, "BatchReadVersionResp"), ]; const MSGPACK_DECODE_ERROR_CODECS: [&str; 2] = [MSGPACK_CODEC_MSGPACK, MSGPACK_CODEC_JSON]; const READER_SIZE_BUCKETS: [&str; 9] = [ "le_4kib", "le_16kib", "le_64kib", "le_128kib", "le_192kib", "le_256kib", "le_512kib", "le_1mib", "gt_1mib", ]; struct BoundaryCase { label: String, size: usize, stored_inline: bool, expected_reader_path: &'static str, } #[derive(Clone, Copy)] enum VersionState { Unversioned, Enabled, Suspended, } impl VersionState { fn label(self) -> &'static str { match self { Self::Unversioned => "unversioned", Self::Enabled => "versioned", Self::Suspended => "suspended-null", } } fn expects_version_id(self) -> bool { matches!(self, Self::Enabled) } } /// Minimal in-process OTLP/HTTP sink. It accepts only the test's loopback /// metric exports and retains decoded protobuf requests for counter snapshots. struct OtlpMetricCollector { endpoint: String, values: MetricValues, decode_values: MetricValues, fallback_values: MetricValues, decode_error_values: MetricValues, task: JoinHandle<()>, } impl OtlpMetricCollector { async fn start() -> TestResult { let listener = TcpListener::bind("127.0.0.1:0").await?; let endpoint = format!("http://{}/v1/metrics", listener.local_addr()?); let values = Arc::new(Mutex::new(BTreeMap::new())); let decode_values = Arc::new(Mutex::new(BTreeMap::new())); let fallback_values = Arc::new(Mutex::new(BTreeMap::new())); let decode_error_values = Arc::new(Mutex::new(BTreeMap::new())); let task_values = values.clone(); let task_decode_values = decode_values.clone(); let task_fallback_values = fallback_values.clone(); let task_decode_error_values = decode_error_values.clone(); let task = tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { break; }; let values = task_values.clone(); let decode_values = task_decode_values.clone(); let fallback_values = task_fallback_values.clone(); let decode_error_values = task_decode_error_values.clone(); tokio::spawn(async move { let _ = hyper::server::conn::http1::Builder::new() .serve_connection( TokioIo::new(stream), service_fn(move |request| { handle_metric_export( request, values.clone(), decode_values.clone(), fallback_values.clone(), decode_error_values.clone(), ) }), ) .await; }); } }); Ok(Self { endpoint, values, decode_values, fallback_values, decode_error_values, task, }) } async fn reader_path_total(&self, path: &str, object_class: &str, size_bucket: &str) -> u64 { self.reader_path_values(path, object_class, size_bucket).await.values().sum() } async fn reader_path_values(&self, path: &str, object_class: &str, size_bucket: &str) -> BTreeMap { self.values .lock() .await .get(&reader_path_metric_key(path, object_class, size_bucket)) .cloned() .unwrap_or_default() .into_iter() .map(|(start_time, (_, value))| (start_time, value)) .collect() } async fn reader_path_totals(&self) -> BTreeMap { self.values .lock() .await .iter() .map(|(key, points)| (key.clone(), points.values().map(|(_, value)| value).sum())) .collect() } async fn msgpack_json_fallback_totals(&self) -> BTreeMap { self.fallback_values .lock() .await .iter() .map(|(key, points)| (key.clone(), points.values().map(|(_, value)| value).sum())) .collect() } async fn msgpack_json_decode_totals(&self) -> BTreeMap { self.decode_values .lock() .await .iter() .map(|(key, points)| (key.clone(), points.values().map(|(_, value)| value).sum())) .collect() } async fn msgpack_json_decode_error_totals(&self) -> BTreeMap { self.decode_error_values .lock() .await .iter() .map(|(key, points)| (key.clone(), points.values().map(|(_, value)| value).sum())) .collect() } async fn reader_path_totals_for(&self, object_class: &str, size_bucket: &str) -> BTreeMap { let values = self.values.lock().await; [INLINE_DIRECT, LEGACY_DUPLEX, EMPTY, REMOTE_TRANSITION] .into_iter() .map(|path| { let total = values .get(&reader_path_metric_key(path, object_class, size_bucket)) .map(|points| points.values().map(|(_, value)| value).sum()) .unwrap_or_default(); (path.to_string(), total) }) .collect() } async fn wait_for_reader_paths_to_settle(&self, object_class: &str, size_bucket: &str) -> TestResult { let deadline = Instant::now() + Duration::from_secs(20); let mut last = self.reader_path_totals_for(object_class, size_bucket).await; let mut unchanged_since = Instant::now(); loop { if unchanged_since.elapsed() >= Duration::from_millis(1_500) { return Ok(()); } if Instant::now() >= deadline { return Err(format!("timed out waiting for reader-path metrics to settle: {last:?}").into()); } sleep(Duration::from_millis(100)).await; let current = self.reader_path_totals_for(object_class, size_bucket).await; if current != last { last = current; unchanged_since = Instant::now(); } } } async fn wait_for_reader_path_total( &self, path: &str, object_class: &str, size_bucket: &str, expected: u64, ) -> TestResult { let deadline = Instant::now() + Duration::from_secs(20); loop { let total = self.reader_path_total(path, object_class, size_bucket).await; if total >= expected { return Ok(total); } if Instant::now() >= deadline { let observed_totals = self.reader_path_totals().await; return Err(format!( "timed out waiting for {READER_PATH_COUNTER}{{path={path}, object_class={object_class}, size_bucket={size_bucket}}} >= {expected}; observed {total}; totals={observed_totals:?}" ) .into()); } sleep(Duration::from_millis(100)).await; } } } impl Drop for OtlpMetricCollector { fn drop(&mut self) { self.task.abort(); } } async fn handle_metric_export( request: Request, values: MetricValues, decode_values: MetricValues, fallback_values: MetricValues, decode_error_values: MetricValues, ) -> Result>, Infallible> { if request.uri().path() != "/v1/metrics" { return Ok(response(StatusCode::NOT_FOUND)); } let gzip = request .headers() .get(CONTENT_ENCODING) .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.eq_ignore_ascii_case("gzip")); let Ok(collected) = request.into_body().collect().await else { return Ok(response(StatusCode::BAD_REQUEST)); }; let body = collected.to_bytes(); if body.len() > 4 * 1024 * 1024 { return Ok(response(StatusCode::PAYLOAD_TOO_LARGE)); } let payload = if gzip { let mut decoder = GzDecoder::new(body.as_ref()); let mut decoded = Vec::new(); if decoder.by_ref().take(4 * 1024 * 1024 + 1).read_to_end(&mut decoded).is_err() || decoded.len() > 4 * 1024 * 1024 { return Ok(response(StatusCode::BAD_REQUEST)); } decoded } else { body.to_vec() }; match ExportMetricsServiceRequest::decode(payload.as_slice()) { Ok(export) => { let mut values = values.lock().await; let mut decode_values = decode_values.lock().await; let mut fallback_values = fallback_values.lock().await; let mut decode_error_values = decode_error_values.lock().await; record_reader_path_metrics(&export, &mut values); record_msgpack_decode_metrics(&export, &mut decode_values); record_msgpack_fallback_metrics(&export, &mut fallback_values); record_msgpack_decode_error_metrics(&export, &mut decode_error_values); Ok(response(StatusCode::OK)) } Err(_) => Ok(response(StatusCode::BAD_REQUEST)), } } fn response(status: StatusCode) -> Response> { Response::builder() .status(status) .body(Full::new(Bytes::new())) .expect("static HTTP response is valid") } fn reader_path_metric_key(path: &str, object_class: &str, size_bucket: &str) -> String { format!("{path}\u{1f}{object_class}\u{1f}{size_bucket}") } fn record_reader_path_metrics(export: &ExportMetricsServiceRequest, values: &mut BTreeMap) { for resource_metrics in &export.resource_metrics { for scope_metrics in &resource_metrics.scope_metrics { for metric in &scope_metrics.metrics { record_reader_path_metric(metric, values); } } } } fn record_reader_path_metric(metric: &Metric, values: &mut BTreeMap) { if metric.name != READER_PATH_COUNTER { return; } let Some(metric::Data::Sum(sum)) = &metric.data else { return; }; for point in &sum.data_points { let Some(path) = attribute_string(&point.attributes, "path") else { continue; }; if ![INLINE_DIRECT, LEGACY_DUPLEX, EMPTY, REMOTE_TRANSITION].contains(&path) { continue; } let Some(object_class) = attribute_string(&point.attributes, "object_class") else { continue; }; let Some(size_bucket) = attribute_string(&point.attributes, "size_bucket") else { continue; }; let Some(number_data_point::Value::AsInt(value)) = point.value.as_ref() else { continue; }; let value = u64::try_from(*value).unwrap_or_default(); values .entry(reader_path_metric_key(path, object_class, size_bucket)) .or_default() .entry(point.start_time_unix_nano) .and_modify(|current| { if point.time_unix_nano >= current.0 { *current = (point.time_unix_nano, value); } }) .or_insert((point.time_unix_nano, value)); } } fn record_msgpack_fallback_metrics(export: &ExportMetricsServiceRequest, values: &mut BTreeMap) { record_msgpack_counter_metrics(export, MSGPACK_JSON_FALLBACK_COUNTER, values, |attributes| { let direction = attribute_string(attributes, DIRECTION_LABEL)?; let message = attribute_string(attributes, MESSAGE_LABEL)?; Some(msgpack_fallback_metric_key(direction, message)) }); } fn msgpack_fallback_metric_key(direction: &str, message: &str) -> String { format!("{direction}\u{1f}{message}") } fn record_msgpack_decode_metrics(export: &ExportMetricsServiceRequest, values: &mut BTreeMap) { record_msgpack_counter_metrics(export, MSGPACK_JSON_DECODE_COUNTER, values, |attributes| { let direction = attribute_string(attributes, DIRECTION_LABEL)?; let message = attribute_string(attributes, MESSAGE_LABEL)?; let codec = attribute_string(attributes, CODEC_LABEL)?; Some(msgpack_decode_metric_key(direction, message, codec)) }); } fn msgpack_decode_metric_key(direction: &str, message: &str, codec: &str) -> String { format!("{direction}\u{1f}{message}\u{1f}{codec}") } fn record_msgpack_decode_error_metrics(export: &ExportMetricsServiceRequest, values: &mut BTreeMap) { record_msgpack_counter_metrics(export, MSGPACK_JSON_DECODE_ERROR_COUNTER, values, |attributes| { let direction = attribute_string(attributes, DIRECTION_LABEL)?; let message = attribute_string(attributes, MESSAGE_LABEL)?; let codec = attribute_string(attributes, CODEC_LABEL)?; Some(msgpack_decode_error_metric_key(direction, message, codec)) }); } fn record_msgpack_counter_metrics( export: &ExportMetricsServiceRequest, counter_name: &str, values: &mut BTreeMap, metric_key: F, ) where F: Fn(&[KeyValue]) -> Option, { for resource_metrics in &export.resource_metrics { for scope_metrics in &resource_metrics.scope_metrics { for metric in &scope_metrics.metrics { if metric.name != counter_name { continue; } let Some(metric::Data::Sum(sum)) = &metric.data else { continue; }; for point in &sum.data_points { let Some(key) = metric_key(&point.attributes) else { continue; }; let Some(number_data_point::Value::AsInt(value)) = point.value.as_ref() else { continue; }; let value = u64::try_from(*value).unwrap_or_default(); values .entry(key) .or_default() .entry(point.start_time_unix_nano) .and_modify(|current| { if point.time_unix_nano >= current.0 { *current = (point.time_unix_nano, value); } }) .or_insert((point.time_unix_nano, value)); } } } } } fn msgpack_decode_error_metric_key(direction: &str, message: &str, codec: &str) -> String { format!("{direction}\u{1f}{message}\u{1f}{codec}") } async fn assert_msgpack_fallback_unchanged( collector: &OtlpMetricCollector, before: &BTreeMap, series: &[(&str, &str)], ) -> TestResult { let after = collector.msgpack_json_fallback_totals().await; for &(direction, message) in series { let key = msgpack_fallback_metric_key(direction, message); assert_eq!( before.get(&key).copied().unwrap_or_default(), after.get(&key).copied().unwrap_or_default(), "fallback counter changed for {direction}/{message}: before={:?}, after={:?}", before.get(&key), after.get(&key), ); } Ok(()) } async fn assert_msgpack_decode_observed(collector: &OtlpMetricCollector, before: &BTreeMap) -> TestResult { let deadline = Instant::now() + Duration::from_secs(20); loop { let after = collector.msgpack_json_decode_totals().await; let missing = [FALLBACK_REQUEST_DIRECTION, FALLBACK_RESPONSE_DIRECTION] .iter() .filter_map(|direction| { let prefix = format!("{direction}\u{1f}"); let suffix = format!("\u{1f}{MSGPACK_CODEC_MSGPACK}"); let before_total = before .iter() .filter(|(key, _)| key.starts_with(&prefix) && key.ends_with(&suffix)) .map(|(_, value)| *value) .sum::(); let after_total = after .iter() .filter(|(key, _)| key.starts_with(&prefix) && key.ends_with(&suffix)) .map(|(_, value)| *value) .sum::(); (after_total <= before_total).then_some(format!("{direction}/{}", MSGPACK_CODEC_MSGPACK)) }) .collect::>(); if missing.is_empty() { return Ok(()); } if Instant::now() >= deadline { return Err(format!("timed out waiting for msgpack decode traffic for {missing:?}; totals={after:?}").into()); } sleep(Duration::from_millis(100)).await; } } async fn assert_msgpack_decode_errors_unchanged( collector: &OtlpMetricCollector, before: &BTreeMap, series: &[(&str, &str)], ) -> TestResult { let after = collector.msgpack_json_decode_error_totals().await; for &(direction, message) in series { for &codec in &MSGPACK_DECODE_ERROR_CODECS { let key = msgpack_decode_error_metric_key(direction, message, codec); assert_eq!( before.get(&key).copied().unwrap_or_default(), after.get(&key).copied().unwrap_or_default(), "decode-error counter changed for {direction}/{message}/{codec}: before={:?}, after={:?}", before.get(&key), after.get(&key), ); } } Ok(()) } fn attribute_string<'a>(attributes: &'a [KeyValue], wanted_key: &str) -> Option<&'a str> { attributes.iter().find_map(|attribute| { if attribute.key != wanted_key { return None; } match attribute.value.as_ref()?.value.as_ref()? { AnyValue::StringValue(value) => Some(value.as_str()), _ => None, } }) } #[test] fn records_msgpack_decode_metric_with_codec_label() { let export = ExportMetricsServiceRequest { resource_metrics: vec![ResourceMetrics { scope_metrics: vec![ScopeMetrics { metrics: vec![Metric { name: MSGPACK_JSON_DECODE_COUNTER.to_string(), data: Some(metric::Data::Sum(Sum { data_points: vec![NumberDataPoint { attributes: vec![ metric_attribute(DIRECTION_LABEL, FALLBACK_RESPONSE_DIRECTION), metric_attribute(MESSAGE_LABEL, "ReadMultipleResp"), metric_attribute(CODEC_LABEL, MSGPACK_CODEC_MSGPACK), ], start_time_unix_nano: 7, time_unix_nano: 11, value: Some(number_data_point::Value::AsInt(5)), ..Default::default() }], ..Default::default() })), ..Default::default() }], ..Default::default() }], ..Default::default() }], }; let mut values = BTreeMap::new(); record_msgpack_decode_metrics(&export, &mut values); let key = msgpack_decode_metric_key(FALLBACK_RESPONSE_DIRECTION, "ReadMultipleResp", MSGPACK_CODEC_MSGPACK); assert_eq!(values.get(&key).and_then(|points| points.get(&7)).copied(), Some((11, 5))); } #[test] fn records_msgpack_fallback_metric_with_direction_and_message_labels() { let export = ExportMetricsServiceRequest { resource_metrics: vec![ResourceMetrics { scope_metrics: vec![ScopeMetrics { metrics: vec![Metric { name: MSGPACK_JSON_FALLBACK_COUNTER.to_string(), data: Some(metric::Data::Sum(Sum { data_points: vec![NumberDataPoint { attributes: vec![ metric_attribute(DIRECTION_LABEL, FALLBACK_RESPONSE_DIRECTION), metric_attribute(MESSAGE_LABEL, "RenameDataResp"), ], start_time_unix_nano: 7, time_unix_nano: 11, value: Some(number_data_point::Value::AsInt(14)), ..Default::default() }], ..Default::default() })), ..Default::default() }], ..Default::default() }], ..Default::default() }], }; let mut values = BTreeMap::new(); record_msgpack_fallback_metrics(&export, &mut values); let key = msgpack_fallback_metric_key(FALLBACK_RESPONSE_DIRECTION, "RenameDataResp"); assert_eq!(values.get(&key).and_then(|points| points.get(&7)).copied(), Some((11, 14))); } #[test] fn records_msgpack_decode_error_metric_with_codec_label() { let export = ExportMetricsServiceRequest { resource_metrics: vec![ResourceMetrics { scope_metrics: vec![ScopeMetrics { metrics: vec![Metric { name: MSGPACK_JSON_DECODE_ERROR_COUNTER.to_string(), data: Some(metric::Data::Sum(Sum { data_points: vec![NumberDataPoint { attributes: vec![ metric_attribute(DIRECTION_LABEL, FALLBACK_REQUEST_DIRECTION), metric_attribute(MESSAGE_LABEL, "ReadMultipleReq"), metric_attribute(CODEC_LABEL, MSGPACK_CODEC_JSON), ], start_time_unix_nano: 7, time_unix_nano: 11, value: Some(number_data_point::Value::AsInt(3)), ..Default::default() }], ..Default::default() })), ..Default::default() }], ..Default::default() }], ..Default::default() }], }; let mut values = BTreeMap::new(); record_msgpack_decode_error_metrics(&export, &mut values); let key = msgpack_decode_error_metric_key(FALLBACK_REQUEST_DIRECTION, "ReadMultipleReq", MSGPACK_CODEC_JSON); assert_eq!(values.get(&key).and_then(|points| points.get(&7)).copied(), Some((11, 3))); } fn metric_attribute(key: &str, value: &str) -> KeyValue { KeyValue { key: key.to_string(), value: Some(opentelemetry_proto::tonic::common::v1::AnyValue { value: Some(AnyValue::StringValue(value.to_string())), }), ..Default::default() } } fn boundary_cases(state: VersionState) -> Vec { let (fast_limit, storage_limit) = match state { VersionState::Enabled => (16 * KIB, 32 * KIB), VersionState::Unversioned => (128 * KIB, 256 * KIB), // A suspended bucket stores its null version using the unversioned // shard threshold, while ObjectInfo keeps version-aware GET semantics. VersionState::Suspended => (16 * KIB, 256 * KIB), }; let mut sizes = vec![0, 16 * KIB - 1, 16 * KIB, 16 * KIB + 1, 32 * KIB - 1, 32 * KIB, 32 * KIB + 1]; if !matches!(state, VersionState::Enabled) { sizes.extend([ 128 * KIB - 1, 128 * KIB, 128 * KIB + 1, 256 * KIB - 1, 256 * KIB, 256 * KIB + 1, ]); } sizes .into_iter() .map(|size| BoundaryCase { label: format!("{size}-bytes"), size, stored_inline: size <= storage_limit, expected_reader_path: if size == 0 { EMPTY } else if size <= fast_limit { INLINE_DIRECT } else { LEGACY_DUPLEX }, }) .collect() } fn payload(size: usize, seed: u8) -> Vec { (0..size) .map(|index| (index as u64).wrapping_mul(2_654_435_761).wrapping_add(seed as u64) as u8) .collect() } fn compressible_payload(size: usize) -> Vec { let pattern = b"RustFS inline compressed fallback control. "; let mut body = Vec::with_capacity(size); while body.len() < size { body.extend_from_slice(pattern); } body.truncate(size); body } fn size_bucket(size: usize) -> &'static str { if size <= 4 * KIB { "le_4kib" } else if size <= 16 * KIB { "le_16kib" } else if size <= 64 * KIB { "le_64kib" } else if size <= 128 * KIB { "le_128kib" } else if size <= 192 * KIB { "le_192kib" } else if size <= 256 * KIB { "le_256kib" } else if size <= 512 * KIB { "le_512kib" } else if size <= 1024 * KIB { "le_1mib" } else { "gt_1mib" } } async fn configure_versioning(client: &Client, bucket: &str, state: VersionState) -> TestResult { let status = match state { VersionState::Unversioned => return Ok(()), VersionState::Enabled => BucketVersioningStatus::Enabled, VersionState::Suspended => BucketVersioningStatus::Suspended, }; client .put_bucket_versioning() .bucket(bucket) .versioning_configuration(VersioningConfiguration::builder().status(status).build()) .send() .await?; Ok(()) } fn configure_reader_metric_cluster(cluster: &mut RustFSTestClusterEnvironment, collector: &OtlpMetricCollector) { cluster.set_env("RUSTFS_OBS_ENDPOINT", collector.endpoint.trim_end_matches("/v1/metrics")); cluster.set_env("RUSTFS_OBS_METRIC_ENDPOINT", &collector.endpoint); cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true"); cluster.set_env("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false"); cluster.set_env("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false"); cluster.set_env("RUSTFS_OBS_METER_INTERVAL", "1"); cluster.set_env("RUSTFS_OBS_USE_STDOUT", "false"); cluster.set_env("RUSTFS_GET_CODEC_STREAMING_ENABLE", "false"); cluster.set_env("RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY", "false"); } fn configure_mixed_msgpack_cluster(cluster: &mut RustFSTestClusterEnvironment, collector: &OtlpMetricCollector) -> TestResult { configure_reader_metric_cluster(cluster, collector); cluster.set_node_env(0, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true")?; cluster.set_node_env(0, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED", "true")?; cluster.set_node_env(1, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true")?; cluster.set_node_env(1, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED", "true")?; cluster.set_node_env(2, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "false")?; cluster.set_node_env(3, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "false")?; Ok(()) } async fn assert_case( cluster: &RustFSTestClusterEnvironment, client: &Client, bucket: &str, state: VersionState, case: &BoundaryCase, seed: u8, ) -> TestResult<(String, Vec, Option, Option)> { let key = format!("{}/{}/{}/{}.bin", state.label(), case.expected_reader_path, case.size, case.label); let body = payload(case.size, seed); let put = client .put_object() .bucket(bucket) .key(&key) .body(ByteStream::from(body.clone())) .send() .await?; let version_id = put.version_id().map(str::to_owned); assert_eq!( version_id.is_some(), state.expects_version_id(), "{} {} PUT version-id compatibility changed: {version_id:?}", state.label(), case.label ); assert_storage_layout(cluster, bucket, &key, version_id.as_deref(), case.stored_inline)?; Ok((key, body, put.e_tag().map(str::to_owned), version_id)) } async fn get_and_assert( client: &Client, bucket: &str, key: &str, expected_body: &[u8], expected_etag: Option<&str>, expected_version_id: Option<&str>, ) -> TestResult { let response = client.get_object().bucket(bucket).key(key).send().await?; assert_eq!( response.content_length(), Some(expected_body.len() as i64), "GET content-length changed for {key}" ); assert_eq!(response.e_tag(), expected_etag, "GET ETag changed for {key}"); assert_eq!(response.version_id(), expected_version_id, "GET version-id changed for {key}"); let body = response.body.collect().await?.into_bytes(); assert_eq!(body.as_ref(), expected_body, "GET body changed for {key}"); Ok(()) } struct ReaderObject<'a> { bucket: &'a str, key: &'a str, body: &'a [u8], etag: Option<&'a str>, version_id: Option<&'a str>, } impl<'a> ReaderObject<'a> { fn new(bucket: &'a str, key: &'a str, body: &'a [u8], etag: Option<&'a str>, version_id: Option<&'a str>) -> Self { Self { bucket, key, body, etag, version_id, } } } struct ReaderPathExpectation<'a> { object: ReaderObject<'a>, expected_path: &'a str, object_class: &'a str, expected_size_bucket: Option<&'a str>, } struct PartNumberReaderPathExpectation<'a> { bucket: &'a str, key: &'a str, expected_part: &'a [u8], full_object_size: usize, object_class: &'a str, expected_path: &'a str, } impl<'a> PartNumberReaderPathExpectation<'a> { fn new( bucket: &'a str, key: &'a str, expected_part: &'a [u8], full_object_size: usize, object_class: &'a str, expected_path: &'a str, ) -> Self { Self { bucket, key, expected_part, full_object_size, object_class, expected_path, } } } impl<'a> ReaderPathExpectation<'a> { fn plain(object: ReaderObject<'a>, expected_path: &'a str) -> Self { Self::for_class(object, expected_path, PLAIN_SINGLE_PART) } fn for_class(object: ReaderObject<'a>, expected_path: &'a str, object_class: &'a str) -> Self { let expected_size_bucket = size_bucket(object.body.len()); Self { object, expected_path, object_class, expected_size_bucket: Some(expected_size_bucket), } } fn with_size_bucket( object: ReaderObject<'a>, expected_path: &'a str, object_class: &'a str, expected_size_bucket: &'a str, ) -> Self { Self { object, expected_path, object_class, expected_size_bucket: Some(expected_size_bucket), } } fn with_any_size_bucket(object: ReaderObject<'a>, expected_path: &'a str, object_class: &'a str) -> Self { Self { object, expected_path, object_class, expected_size_bucket: None, } } } async fn assert_reader_path( collector: &OtlpMetricCollector, client: &Client, expectation: ReaderPathExpectation<'_>, ) -> TestResult { let ReaderPathExpectation { object, expected_path, object_class, expected_size_bucket, } = expectation; let paths = [INLINE_DIRECT, LEGACY_DUPLEX, EMPTY, REMOTE_TRANSITION]; let size_buckets: Vec<&str> = match expected_size_bucket { Some(expected_size_bucket) => vec![expected_size_bucket], None => READER_SIZE_BUCKETS.to_vec(), }; let size_buckets = size_buckets.as_slice(); let mut before = BTreeMap::<&str, BTreeMap<&str, u64>>::new(); for &size_bucket in size_buckets { let mut bucket_before = BTreeMap::new(); for path in paths { bucket_before.insert(path, collector.reader_path_total(path, object_class, size_bucket).await); } before.insert(size_bucket, bucket_before); } get_and_assert(client, object.bucket, object.key, object.body, object.etag, object.version_id).await?; let selected_size_bucket = match expected_size_bucket { Some(expected_size_bucket) => { let path_before = before .get(expected_size_bucket) .and_then(|values| values.get(expected_path)) .copied() .ok_or_else(|| format!("reader-path baseline missing for size bucket {expected_size_bucket}"))?; let expected_after = collector .wait_for_reader_path_total(expected_path, object_class, expected_size_bucket, path_before + 1) .await?; assert!( expected_after > path_before, "{READER_PATH_COUNTER}{{path={expected_path}}} must advance for {}", object.key ); expected_size_bucket } None => { let deadline = Instant::now() + Duration::from_secs(20); loop { let mut matched_size_bucket = None; for &size_bucket in size_buckets { let path_before = before .get(size_bucket) .and_then(|values| values.get(expected_path)) .copied() .ok_or_else(|| format!("reader-path baseline missing for size bucket {size_bucket}"))?; let path_after = collector.reader_path_total(expected_path, object_class, size_bucket).await; if path_after < path_before + 1 { continue; } let mut conflicting = false; for path in paths { if path == expected_path || expected_path == EMPTY { continue; } let path_before = before .get(size_bucket) .and_then(|values| values.get(path)) .copied() .ok_or_else(|| format!("reader-path baseline missing for size bucket {size_bucket}"))?; let path_after = collector.reader_path_total(path, object_class, size_bucket).await; if path_after != path_before { conflicting = true; break; } } if !conflicting { matched_size_bucket = Some(size_bucket); break; } } if let Some(size_bucket) = matched_size_bucket { break size_bucket; } if Instant::now() >= deadline { let mut observed = BTreeMap::<&str, BTreeMap<&str, u64>>::new(); for &size_bucket in size_buckets { let mut bucket_after = BTreeMap::new(); for path in paths { bucket_after.insert(path, collector.reader_path_total(path, object_class, size_bucket).await); } observed.insert(size_bucket, bucket_after); } return Err(format!( "timed out waiting for {READER_PATH_COUNTER}{{object_class={object_class}, path={expected_path}}} to advance in any candidate size bucket; before={before:?}; after={observed:?}" ) .into()); } sleep(Duration::from_millis(100)).await; } } }; collector .wait_for_reader_paths_to_settle(object_class, selected_size_bucket) .await?; for path in paths { if path == expected_path { continue; } if expected_path == EMPTY { continue; } assert_eq!( collector.reader_path_total(path, object_class, selected_size_bucket).await, before .get(selected_size_bucket) .and_then(|values| values.get(path)) .copied() .ok_or_else(|| format!("reader-path baseline missing for size bucket {selected_size_bucket}"))?, "{READER_PATH_COUNTER}{{path={path}, object_class={object_class}, size_bucket={selected_size_bucket}}} must not advance for {}; expected {expected_path} only", object.key ); } Ok(()) } async fn assert_part_number_reader_path( collector: &OtlpMetricCollector, client: &Client, expectation: PartNumberReaderPathExpectation<'_>, ) -> TestResult { let PartNumberReaderPathExpectation { bucket, key, expected_part, full_object_size, object_class, expected_path, } = expectation; let paths = [INLINE_DIRECT, LEGACY_DUPLEX, EMPTY, REMOTE_TRANSITION]; let size_bucket = size_bucket(full_object_size); let mut before = BTreeMap::<&str, u64>::new(); for path in paths { before.insert(path, collector.reader_path_total(path, object_class, size_bucket).await); } let response = client.get_object().bucket(bucket).key(key).part_number(2).send().await?; assert_eq!( response.content_length(), Some(expected_part.len() as i64), "partNumber GET content-length changed for {key}" ); let body = response.body.collect().await?.into_bytes(); assert_eq!(body.as_ref(), expected_part, "partNumber GET body changed for {key}"); collector .wait_for_reader_path_total(expected_path, object_class, size_bucket, before[expected_path] + 1) .await?; collector.wait_for_reader_paths_to_settle(object_class, size_bucket).await?; for path in paths { if path == expected_path { continue; } assert_eq!( collector.reader_path_total(path, object_class, size_bucket).await, before[path], "{READER_PATH_COUNTER}{{path={path}, object_class={object_class}, size_bucket={size_bucket}}} must not advance for partNumber GET {key}; expected {expected_path} only" ); } Ok(()) } async fn put_two_part_multipart(client: &Client, bucket: &str, key: &str) -> TestResult<(Vec, Vec, Option)> { let part1 = payload(MPU_PART_1_SIZE, 0xA5); let part2 = payload(MPU_PART_2_SIZE, 0x5A); let create = client.create_multipart_upload().bucket(bucket).key(key).send().await?; let upload_id = create .upload_id() .ok_or("CreateMultipartUpload returned no upload id")? .to_string(); let uploaded_part1 = client .upload_part() .bucket(bucket) .key(key) .upload_id(&upload_id) .part_number(1) .body(ByteStream::from(part1.clone())) .send() .await?; let uploaded_part2 = client .upload_part() .bucket(bucket) .key(key) .upload_id(&upload_id) .part_number(2) .body(ByteStream::from(part2.clone())) .send() .await?; let completed = CompletedMultipartUpload::builder() .parts( CompletedPart::builder() .part_number(1) .e_tag(uploaded_part1.e_tag().unwrap_or_default()) .build(), ) .parts( CompletedPart::builder() .part_number(2) .e_tag(uploaded_part2.e_tag().unwrap_or_default()) .build(), ) .build(); let complete = client .complete_multipart_upload() .bucket(bucket) .key(key) .upload_id(&upload_id) .multipart_upload(completed) .send() .await?; let mut body = part1; body.extend_from_slice(&part2); Ok((body, part2, complete.e_tag().map(str::to_owned))) } async fn signed_admin_request( base_url: &str, method: Method, path: &str, body: Option<&str>, access_key: &str, secret_key: &str, ) -> TestResult<(reqwest::StatusCode, String)> { let url = format!("{base_url}{path}"); let uri = url.parse::()?; let authority = uri.authority().ok_or("request URL missing authority")?.to_string(); let body_bytes = body.map(|value| value.as_bytes().to_vec()).unwrap_or_default(); let request = http::Request::builder() .method(method.clone()) .uri(uri) .header(HOST, authority) .header("x-amz-content-sha256", UNSIGNED_PAYLOAD); let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1"); let client = local_http_client(); let mut request_builder = client.request(method, url.as_str()); for (name, value) in signed.headers() { request_builder = request_builder.header(name, value); } if !body_bytes.is_empty() { request_builder = request_builder.body(body_bytes); } let response = request_builder.send().await?; let status = response.status(); let text = response.text().await?; Ok((status, text)) } fn unique_tier_name() -> String { format!("COLDTIER{}", Uuid::new_v4().simple()).to_ascii_uppercase() } async fn add_rustfs_tier(hot: &RustFSTestClusterEnvironment, cold: &RustFSTestEnvironment, tier_name: &str) -> TestResult { let response = submit_rustfs_tier(hot, cold, tier_name).await?; wait_for_tier_verifiable(hot, tier_name, &response).await } async fn submit_rustfs_tier( hot: &RustFSTestClusterEnvironment, cold: &RustFSTestEnvironment, tier_name: &str, ) -> TestResult { let body = serde_json::json!({ "type": "rustfs", "rustfs": { "name": tier_name, "endpoint": cold.url.as_str(), "accessKey": cold.access_key.as_str(), "secretKey": cold.secret_key.as_str(), "bucket": TIER_BUCKET, "prefix": TIER_PREFIX, "region": "us-east-1", "storageClass": "" } }) .to_string(); let deadline = Instant::now() + Duration::from_secs(30); let mut attempts = Vec::new(); let final_error = loop { let (status, response) = signed_admin_request( &hot.nodes[0].url, Method::PUT, "/rustfs/admin/v3/tier", Some(&body), &hot.access_key, &hot.secret_key, ) .await?; let attempt = format!("status={status}, body={}", compact_body(&response)); if status.is_success() { return Ok(format!("status={status}, body={response}")); } attempts.push(attempt); if Instant::now() >= deadline { break attempts.join("; "); } if !is_retryable_add_tier_error(&response) { break attempts.join("; "); } sleep(Duration::from_millis(500)).await; }; Err(format!("AddTier(RustFS) failed after readiness polling: {final_error}").into()) } async fn wait_for_tier_verifiable(hot: &RustFSTestClusterEnvironment, tier_name: &str, add_tier_response: &str) -> TestResult { let deadline = Instant::now() + Duration::from_secs(60); let final_error = loop { let snapshot = tier_readiness_snapshot(hot, tier_name).await?; if snapshot.iter().any(|node| node.verify_status.is_success()) { return Ok(()); } if snapshot.iter().any(|node| !is_retryable_tier_error(&node.verify_body)) { break format!("non-retryable tier verification failure: {}", format_tier_readiness_snapshot(&snapshot)); } if Instant::now() >= deadline { break format_tier_readiness_snapshot(&snapshot); } sleep(Duration::from_millis(500)).await; }; Err(format!( "tier {tier_name} was not verifiable on any hot node within 60s after AddTier({add_tier_response}): {final_error}" ) .into()) } async fn wait_for_tier_converged(hot: &RustFSTestClusterEnvironment, tier_name: &str, add_tier_responses: &str) -> TestResult { let deadline = Instant::now() + Duration::from_secs(60); let final_error = loop { let snapshot = tier_readiness_snapshot(hot, tier_name).await?; if snapshot .iter() .all(|node| node.list_status.is_success() && node.list_has_tier && node.verify_status.is_success()) { return Ok(()); } if snapshot.iter().any(|node| { !node.list_status.is_success() || (!node.verify_status.is_success() && !is_retryable_tier_error(&node.verify_body)) }) { break format_tier_readiness_snapshot(&snapshot); } if Instant::now() >= deadline { break format_tier_readiness_snapshot(&snapshot); } sleep(Duration::from_millis(500)).await; }; Err(format!( "tier {tier_name} did not converge on every hot node within 60s after AddTier({add_tier_responses}): {final_error}" ) .into()) } struct TierNodeReadiness { node_index: usize, node_url: String, list_status: StatusCode, list_has_tier: bool, list_body: String, verify_status: StatusCode, verify_body: String, } async fn tier_readiness_snapshot(hot: &RustFSTestClusterEnvironment, tier_name: &str) -> TestResult> { let mut snapshot = Vec::with_capacity(hot.nodes.len()); for (node_index, node) in hot.nodes.iter().enumerate() { let (list_status, list_body) = signed_admin_request(&node.url, Method::GET, "/rustfs/admin/v3/tier", None, &hot.access_key, &hot.secret_key).await?; let (verify_status, verify_body) = signed_admin_request( &node.url, Method::GET, &format!("/rustfs/admin/v3/tier/{tier_name}"), None, &hot.access_key, &hot.secret_key, ) .await?; snapshot.push(TierNodeReadiness { node_index, node_url: node.url.clone(), list_status, list_has_tier: tier_list_contains(&list_body, tier_name), list_body, verify_status, verify_body, }); } Ok(snapshot) } fn tier_list_contains(response: &str, tier_name: &str) -> bool { serde_json::from_str::(response) .ok() .and_then(|value| value.as_array().cloned()) .is_some_and(|tiers| { tiers.iter().any(|tier| { tier.get("name").and_then(serde_json::Value::as_str) == Some(tier_name) || tier .get("rustfs") .and_then(|rustfs| rustfs.get("name")) .and_then(serde_json::Value::as_str) == Some(tier_name) }) }) } fn format_tier_readiness_snapshot(snapshot: &[TierNodeReadiness]) -> String { snapshot .iter() .map(|node| { format!( "node {} {} list_status={} list_has_tier={} list_body={} verify_status={} verify_body={}", node.node_index, node.node_url, node.list_status, node.list_has_tier, compact_body(&node.list_body), node.verify_status, compact_body(&node.verify_body) ) }) .collect::>() .join("; ") } fn compact_body(body: &str) -> String { const MAX_BODY_CHARS: usize = 1024; let mut value = body.split_whitespace().collect::>().join(" "); if value.chars().count() > MAX_BODY_CHARS { value = value.chars().take(MAX_BODY_CHARS).collect::(); value.push_str("..."); } value } fn is_retryable_tier_error(response: &str) -> bool { response.contains("TierNotFound") || response.contains("NoSuchTier") || (response.contains("TierVerificationFailed") && response.contains("Remote tier configuration is being replaced")) || response.contains("TierNotFound") || response.contains("NoSuchTier") } fn is_retryable_add_tier_error(response: &str) -> bool { response.contains("Remote tier configuration is already being replaced") } async fn start_manual_transition_job(hot: &RustFSTestClusterEnvironment, bucket: &str) -> TestResult { let path = format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&async=true&dryRun=true&maxObjects=1"); let (status, response) = signed_admin_request(&hot.nodes[0].url, Method::POST, &path, None, &hot.access_key, &hot.secret_key).await?; assert_eq!( status, StatusCode::ACCEPTED, "async manual transition run must be accepted: {}", compact_body(&response) ); let value: serde_json::Value = serde_json::from_str(&response)?; assert_eq!( value["state"].as_str(), Some("accepted"), "manual transition run state changed: {response}" ); assert_eq!( value["mode"].as_str(), Some("durable_job"), "manual transition run mode changed: {response}" ); value["job_id"] .as_str() .map(str::to_owned) .ok_or_else(|| format!("manual transition run response omitted job_id: {response}").into()) } async fn start_manual_transition_job_on_node( hot: &RustFSTestClusterEnvironment, node_index: usize, bucket: &str, prefix: &str, tier_name: &str, dry_run: bool, max_objects: u64, ) -> TestResult<(StatusCode, String)> { let bucket = urlencoding::encode(bucket); let prefix = urlencoding::encode(prefix); let tier = urlencoding::encode(tier_name); let path = format!( "/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={prefix}&tier={tier}&dryRun={dry_run}&maxObjects={max_objects}&mode=async" ); signed_admin_request(&hot.nodes[node_index].url, Method::POST, &path, None, &hot.access_key, &hot.secret_key).await } async fn read_manual_transition_job_status_endpoint( hot: &RustFSTestClusterEnvironment, node_index: usize, status_endpoint: &str, ) -> TestResult { let (status, response) = signed_admin_request( &hot.nodes[node_index].url, Method::GET, status_endpoint, None, &hot.access_key, &hot.secret_key, ) .await?; assert_eq!( status, StatusCode::OK, "manual transition job status endpoint was not readable on node {node_index}: {}", compact_body(&response) ); Ok(serde_json::from_str(&response)?) } async fn wait_for_manual_transition_job_terminal( hot: &RustFSTestClusterEnvironment, node_index: usize, job_id: &str, retry_missing: bool, ) -> TestResult { let path = format!("/rustfs/admin/v3/ilm/transition/jobs/{job_id}"); let deadline = Instant::now() + Duration::from_secs(30); loop { let (status, response) = signed_admin_request(&hot.nodes[node_index].url, Method::GET, &path, None, &hot.access_key, &hot.secret_key).await?; if retry_missing && status == StatusCode::NOT_FOUND && Instant::now() < deadline { sleep(Duration::from_millis(200)).await; continue; } assert_eq!( status, StatusCode::OK, "manual transition job status read failed: {}", compact_body(&response) ); let value: serde_json::Value = serde_json::from_str(&response)?; match value["status"].as_str() { Some("running") if Instant::now() < deadline => sleep(Duration::from_millis(200)).await, Some("running") => { return Err(format!("manual transition job {job_id} stayed running for 30s: {response}").into()); } Some(_) => return Ok(value), None => return Err(format!("manual transition job status response omitted status: {response}").into()), } } } fn transition_rule(tier_name: &str) -> TestResult { Ok(LifecycleRule::builder() .id("inline-fallback-transition") .filter(LifecycleRuleFilter::builder().prefix("transition/").build()) .transitions( Transition::builder() .days(0) .storage_class(TransitionStorageClass::from(tier_name)) .build(), ) .status(ExpirationStatus::Enabled) .build()?) } async fn wait_for_transition(client: &Client, bucket: &str, key: &str, tier_name: &str) -> TestResult { let deadline = Instant::now() + Duration::from_secs(90); loop { let head = client.head_object().bucket(bucket).key(key).send().await?; if head.storage_class().map(|storage_class| storage_class.as_str()) == Some(tier_name) { return Ok(()); } if Instant::now() >= deadline { return Err(format!( "object {bucket}/{key} was not transitioned to {tier_name} within 90s (storage_class={:?})", head.storage_class() ) .into()); } sleep(Duration::from_millis(500)).await; } } async fn cold_tier_object_count(cold_client: &Client) -> TestResult { Ok(cold_client .list_objects_v2() .bucket(TIER_BUCKET) .send() .await? .contents() .len()) } async fn put_lifecycle_with_transition_retry(client: &Client, bucket: &str, tier_name: &str) -> TestResult { let deadline = Instant::now() + Duration::from_secs(30); loop { let lifecycle = BucketLifecycleConfiguration::builder() .rules(transition_rule(tier_name)?) .build()?; match client .put_bucket_lifecycle_configuration() .bucket(bucket) .lifecycle_configuration(lifecycle) .send() .await { Ok(_) => return Ok(()), Err(err) => { let message = format!("{err:?}"); if !message.contains("invalid tier") || Instant::now() >= deadline { return Err(err.into()); } } } sleep(Duration::from_millis(500)).await; } } fn assert_storage_layout( cluster: &RustFSTestClusterEnvironment, bucket: &str, key: &str, version_id: Option<&str>, expected_inline: bool, ) -> TestResult { for (node_index, node) in cluster.nodes.iter().enumerate() { let object_dir = Path::new(&node.data_dir).join(bucket).join(key); let meta_path = object_dir.join("xl.meta"); assert!(meta_path.is_file(), "node {node_index} is missing xl.meta for {key}"); let metadata = rustfs_filemeta::FileMeta::load(&std::fs::read(&meta_path)?)?; let file_info = metadata.into_fileinfo(bucket, key, version_id.unwrap_or_default(), true, false, true)?; assert_eq!( file_info.inline_data(), expected_inline, "node {node_index} inline metadata differs for {key} (version {version_id:?})" ); let has_part_file = WalkDir::new(&object_dir) .into_iter() .filter_map(Result::ok) .any(|entry| entry.file_type().is_file() && entry.file_name().to_string_lossy().starts_with("part.")); assert_eq!( has_part_file, !expected_inline, "node {node_index} physical shard layout differs for {key} (version {version_id:?})" ); } Ok(()) } #[tokio::test] #[serial] async fn four_node_inline_storage_and_get_boundaries() -> TestResult { init_logging(); let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut cluster, &collector); cluster.start().await?; for (state_index, state) in [VersionState::Unversioned, VersionState::Enabled, VersionState::Suspended] .into_iter() .enumerate() { let bucket = format!("inline-boundary-{}", state.label()); cluster.create_test_bucket(&bucket).await?; let client = cluster.create_s3_client(state_index % cluster.nodes.len())?; configure_versioning(&client, &bucket, state).await?; let mut objects = Vec::new(); for (case_index, case) in boundary_cases(state).into_iter().enumerate() { let (key, body, etag, version_id) = assert_case(&cluster, &client, &bucket, state, &case, (state_index * 31 + case_index) as u8).await?; objects.push((key, body, etag, version_id, case.expected_reader_path)); } for (key, body, etag, version_id, expected_path) in &objects { assert_reader_path( &collector, &client, ReaderPathExpectation::plain( ReaderObject::new(&bucket, key, body, etag.as_deref(), version_id.as_deref()), expected_path, ), ) .await?; } } // Range is an explicit fallback control even for an otherwise eligible // unversioned object and must select a fallback reader. let client = cluster.create_s3_client(0)?; let bucket = "inline-boundary-unversioned"; let key = "unversioned/inline_direct/131072/131072-bytes.bin"; let range_size_bucket = size_bucket(128 * KIB); let mut before = BTreeMap::new(); for path in [INLINE_DIRECT, LEGACY_DUPLEX, EMPTY, REMOTE_TRANSITION] { before.insert(path, collector.reader_path_total(path, RANGE, range_size_bucket).await); } let ranged = client.get_object().bucket(bucket).key(key).range("bytes=0-31").send().await?; assert_eq!(ranged.content_length(), Some(32), "range GET must retain S3 response semantics"); assert_eq!( ranged.body.collect().await?.into_bytes().len(), 32, "range GET must retain requested body length" ); collector .wait_for_reader_path_total(LEGACY_DUPLEX, "range", range_size_bucket, before[LEGACY_DUPLEX] + 1) .await?; collector.wait_for_reader_paths_to_settle(RANGE, range_size_bucket).await?; assert_eq!( collector.reader_path_total(INLINE_DIRECT, "range", range_size_bucket).await, before[INLINE_DIRECT], "range GET must not select inline_direct" ); assert_eq!( collector.reader_path_total(EMPTY, "range", range_size_bucket).await, before[EMPTY], "range GET must not select empty" ); Ok(()) } #[tokio::test] #[serial] async fn four_node_inline_fallback_controls() -> TestResult { init_logging(); let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut cluster, &collector); let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key); cluster.start().await?; let bucket = "inline-fallback-controls"; cluster.create_test_bucket(bucket).await?; let client = cluster.create_s3_client(0)?; let multipart_key = "multipart/two-part.bin"; let (multipart_body, second_part, multipart_etag) = put_two_part_multipart(&client, bucket, multipart_key).await?; assert_reader_path( &collector, &client, ReaderPathExpectation::for_class( ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None), LEGACY_DUPLEX, MULTIPART, ), ) .await?; assert_part_number_reader_path( &collector, &client, PartNumberReaderPathExpectation::new(bucket, multipart_key, &second_part, multipart_body.len(), MULTIPART, LEGACY_DUPLEX), ) .await?; let encrypted_key = "encrypted/sse-s3.bin"; let encrypted_body = payload(16 * KIB, 0xE3); let encrypted_put = client .put_object() .bucket(bucket) .key(encrypted_key) .server_side_encryption(ServerSideEncryption::Aes256) .body(ByteStream::from(encrypted_body.clone())) .send() .await?; let encrypted_head = client.head_object().bucket(bucket).key(encrypted_key).send().await?; assert_eq!( encrypted_head.server_side_encryption(), Some(&ServerSideEncryption::Aes256), "HEAD must preserve SSE-S3 metadata for {encrypted_key}" ); assert_reader_path( &collector, &client, ReaderPathExpectation::with_any_size_bucket( ReaderObject::new(bucket, encrypted_key, &encrypted_body, encrypted_put.e_tag(), None), LEGACY_DUPLEX, ENCRYPTED, ), ) .await?; Ok(()) } #[tokio::test] #[serial] async fn four_node_compressed_inline_fallback() -> TestResult { init_logging(); let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut cluster, &collector); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.start().await?; let bucket = "inline-compressed-fallback"; cluster.create_test_bucket(bucket).await?; let client = cluster.create_s3_client(0)?; let key = "compressed/repeated.txt"; let body = compressible_payload(64 * KIB); let put = client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from(body.clone())) .send() .await?; assert_reader_path( &collector, &client, ReaderPathExpectation::with_size_bucket( ReaderObject::new(bucket, key, &body, put.e_tag(), None), LEGACY_DUPLEX, COMPRESSED, size_bucket(4 * KIB), ), ) .await?; Ok(()) } #[tokio::test] #[serial] async fn four_node_multipart_ignores_disk_compression_fallback() -> TestResult { init_logging(); let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut cluster, &collector); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); cluster.start().await?; let bucket = "inline-multipart-compression-fallback"; cluster.create_test_bucket(bucket).await?; let client = cluster.create_s3_client(0)?; let key = "multipart/compression-disabled.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), ) .await?; assert_part_number_reader_path( &collector, &client, PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), MULTIPART, LEGACY_DUPLEX), ) .await?; Ok(()) } #[tokio::test] #[serial] async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> TestResult { init_logging(); let collector = OtlpMetricCollector::start().await?; let mut cluster = RustFSTestClusterEnvironment::new(4).await?; let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); configure_mixed_msgpack_cluster(&mut cluster, &collector)?; cluster.start().await?; let decode_before = collector.msgpack_json_decode_totals().await; let fallback_before = collector.msgpack_json_fallback_totals().await; let decode_errors_before = collector.msgpack_json_decode_error_totals().await; let bucket = "inline-mixed-msgpack-controls"; cluster.create_test_bucket(bucket).await?; let client = cluster.create_s3_client(0)?; let multipart_key = "mixed/multipart.bin"; let (multipart_body, second_part, multipart_etag) = put_two_part_multipart(&client, bucket, multipart_key).await?; assert_reader_path( &collector, &client, ReaderPathExpectation::for_class( ReaderObject::new(bucket, multipart_key, &multipart_body, multipart_etag.as_deref(), None), LEGACY_DUPLEX, MULTIPART, ), ) .await?; assert_part_number_reader_path( &collector, &client, PartNumberReaderPathExpectation::new(bucket, multipart_key, &second_part, multipart_body.len(), MULTIPART, LEGACY_DUPLEX), ) .await?; assert_msgpack_decode_observed(&collector, &decode_before).await?; assert_msgpack_fallback_unchanged(&collector, &fallback_before, &MSGPACK_FALLBACK_CONTROL_SERIES).await?; assert_msgpack_decode_errors_unchanged(&collector, &decode_errors_before, &MSGPACK_FALLBACK_CONTROL_SERIES).await?; let encrypted_key = "encrypted/sse-s3.bin"; let encrypted_body = payload(16 * KIB, 0xE3); let encrypted_put = client .put_object() .bucket(bucket) .key(encrypted_key) .server_side_encryption(ServerSideEncryption::Aes256) .body(ByteStream::from(encrypted_body.clone())) .send() .await?; let encrypted_head = client.head_object().bucket(bucket).key(encrypted_key).send().await?; assert_eq!( encrypted_head.server_side_encryption(), Some(&ServerSideEncryption::Aes256), "HEAD must preserve SSE-S3 metadata for {encrypted_key}" ); assert_reader_path( &collector, &client, ReaderPathExpectation::with_any_size_bucket( ReaderObject::new(bucket, encrypted_key, &encrypted_body, encrypted_put.e_tag(), None), LEGACY_DUPLEX, ENCRYPTED, ), ) .await?; let compressed_key = "compressed/repeated.txt"; let compressed_body = compressible_payload(64 * KIB); let compressed_put = client .put_object() .bucket(bucket) .key(compressed_key) .body(ByteStream::from(compressed_body.clone())) .send() .await?; assert_reader_path( &collector, &client, ReaderPathExpectation::with_any_size_bucket( ReaderObject::new(bucket, compressed_key, &compressed_body, compressed_put.e_tag(), None), LEGACY_DUPLEX, COMPRESSED, ), ) .await?; assert_msgpack_fallback_unchanged(&collector, &fallback_before, &MSGPACK_FALLBACK_CONTROL_SERIES).await?; assert_msgpack_decode_errors_unchanged(&collector, &decode_errors_before, &MSGPACK_FALLBACK_CONTROL_SERIES).await?; Ok(()) } #[tokio::test] #[serial] async fn four_node_add_tier_converges() -> TestResult { init_logging(); let mut cold = RustFSTestEnvironment::new().await?; cold.access_key = "inlineconcurrentcoldadmin".to_string(); cold.secret_key = "inlineconcurrentcoldsecret".to_string(); cold.start_rustfs_server_without_cleanup(vec![]).await?; cold.create_s3_client().create_bucket().bucket(TIER_BUCKET).send().await?; let mut hot = RustFSTestClusterEnvironment::new(4).await?; hot.start().await?; let tier_name = unique_tier_name(); add_rustfs_tier(&hot, &cold, &tier_name).await?; wait_for_tier_converged(&hot, &tier_name, "AddTier convergence").await } #[tokio::test] #[serial] async fn four_node_add_tier_converges_after_offline_node_restart_without_second_mutation() -> TestResult { init_logging(); let mut cold = RustFSTestEnvironment::new().await?; cold.access_key = "inlineofflinecoldadmin".to_string(); cold.secret_key = "inlineofflinecoldsecret".to_string(); cold.start_rustfs_server_without_cleanup(vec![]).await?; cold.create_s3_client().create_bucket().bucket(TIER_BUCKET).send().await?; let mut hot = RustFSTestClusterEnvironment::new(4).await?; hot.start().await?; let tier_name = unique_tier_name(); let add_tier_response = submit_rustfs_tier(&hot, &cold, &tier_name).await?; hot.stop_node(3)?; hot.start_node(3).await?; wait_for_tier_converged(&hot, &tier_name, &add_tier_response).await } #[tokio::test] #[serial] async fn four_node_manual_transition_job_status_survives_node_restart() -> TestResult { init_logging(); let mut hot = RustFSTestClusterEnvironment::new(4).await?; hot.start().await?; let hot_client = hot.create_s3_client(0)?; let bucket = format!("manual-transition-job-{}", Uuid::new_v4().simple()); hot_client.create_bucket().bucket(&bucket).send().await?; let job_id = start_manual_transition_job(&hot, &bucket).await?; let terminal = wait_for_manual_transition_job_terminal(&hot, 0, &job_id, false).await?; assert_eq!( terminal["status"].as_str(), Some("completed"), "dry-run manual transition job should complete: {terminal}" ); assert_eq!(terminal["job_id"].as_str(), Some(job_id.as_str())); assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(terminal["dry_run"].as_bool(), Some(true)); hot.stop_node(3)?; hot.start_node(3).await?; let after_restart = wait_for_manual_transition_job_terminal(&hot, 3, &job_id, true).await?; assert_eq!(after_restart["status"], terminal["status"], "terminal job status changed after restart"); assert_eq!(after_restart["job_id"].as_str(), Some(job_id.as_str())); assert_eq!(after_restart["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(after_restart["dry_run"].as_bool(), Some(true)); let job_endpoint = format!("/rustfs/admin/v3/ilm/transition/jobs/{job_id}"); let (cancel_status, cancel_body) = signed_admin_request(&hot.nodes[3].url, Method::DELETE, &job_endpoint, None, &hot.access_key, &hot.secret_key).await?; assert_eq!( cancel_status, StatusCode::OK, "terminal manual transition job cancel after restart failed: {}", compact_body(&cancel_body) ); let cancel_value: serde_json::Value = serde_json::from_str(&cancel_body)?; assert_eq!( cancel_value["status"], after_restart["status"], "terminal status changed after restart cancel" ); assert_eq!(cancel_value["job_id"].as_str(), Some(job_id.as_str())); assert_eq!(cancel_value["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(cancel_value["dry_run"].as_bool(), Some(true)); assert_eq!(cancel_value["cancel_requested"].as_bool(), Some(false)); let missing_job_id = Uuid::new_v4(); let (missing_status, missing_body) = signed_admin_request( &hot.nodes[3].url, Method::GET, &format!("/rustfs/admin/v3/ilm/transition/jobs/{missing_job_id}"), None, &hot.access_key, &hot.secret_key, ) .await?; assert_eq!( missing_status, StatusCode::NOT_FOUND, "unknown manual transition job must remain a 404 after restart: {}", compact_body(&missing_body) ); assert!( missing_body.contains("NoSuchKey") || missing_body.contains("NoSuchKey"), "unknown manual transition job should return NoSuchKey, body: {}", compact_body(&missing_body) ); Ok(()) } #[tokio::test] #[serial] async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult { init_logging(); let mut cold = RustFSTestEnvironment::new().await?; cold.access_key = "inlinedistadmissioncoldadmin".to_string(); cold.secret_key = "inlinedistadmissioncoldsecret".to_string(); cold.start_rustfs_server_without_cleanup(vec![]).await?; let cold_client = cold.create_s3_client(); cold_client.create_bucket().bucket(TIER_BUCKET).send().await?; let mut hot = RustFSTestClusterEnvironment::new(4).await?; hot.set_env("RUSTFS_SCANNER_ENABLED", "false"); hot.set_env("RUSTFS_SCANNER_CYCLE", "3600"); hot.set_env("RUSTFS_MAX_TRANSITION_WORKERS", "1"); hot.set_env("RUSTFS_TRANSITION_QUEUE_CAPACITY", "1"); hot.start().await?; let hot_client = hot.create_s3_client(0)?; let tier_name = unique_tier_name(); add_rustfs_tier(&hot, &cold, &tier_name).await?; let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple()); let prefix = "transition/distributed-admission/"; hot_client.create_bucket().bucket(&bucket).send().await?; put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?; for index in 0u8..64 { let key = format!("{prefix}object-{index:02}.bin"); hot_client .put_object() .bucket(&bucket) .key(key) .body(ByteStream::from(payload(64 * KIB, index))) .send() .await?; } let (node0, node1) = tokio::join!( start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64), start_manual_transition_job_on_node(&hot, 1, &bucket, prefix, &tier_name, false, 64) ); let responses = [(0usize, node0?), (1usize, node1?)]; let accepted = responses .iter() .find(|(_, (status, _))| *status == StatusCode::ACCEPTED) .ok_or_else(|| format!("one distributed async run must be accepted: {responses:#?}"))?; let conflict = responses .iter() .find(|(_, (status, _))| *status == StatusCode::CONFLICT) .ok_or_else(|| format!("one distributed async run must report conflict: {responses:#?}"))?; assert_eq!( responses .iter() .filter(|(_, (status, _))| *status == StatusCode::ACCEPTED) .count(), 1, "distributed admission must allow exactly one winner: {responses:#?}" ); assert_eq!( responses .iter() .filter(|(_, (status, _))| *status == StatusCode::CONFLICT) .count(), 1, "distributed admission must reject exactly one overlapping contender: {responses:#?}" ); let accepted_body: serde_json::Value = serde_json::from_str(&accepted.1.1)?; let conflict_body: serde_json::Value = serde_json::from_str(&conflict.1.1)?; let job_id = accepted_body["job_id"] .as_str() .ok_or_else(|| format!("accepted response omitted job_id: {}", accepted.1.1))?; let status_endpoint = accepted_body["status_endpoint"] .as_str() .ok_or_else(|| format!("accepted response omitted status_endpoint: {}", accepted.1.1))?; assert_eq!(accepted_body["state"].as_str(), Some("accepted")); assert_eq!(accepted_body["mode"].as_str(), Some("durable_job")); assert_eq!(accepted_body["cancel_endpoint"].as_str(), Some(status_endpoint)); assert_eq!(conflict_body["state"].as_str(), Some("conflict")); assert_eq!(conflict_body["mode"].as_str(), Some("durable_job")); assert_eq!(conflict_body["active_job_id"].as_str(), Some(job_id)); assert_eq!(conflict_body["status_endpoint"].as_str(), Some(status_endpoint)); assert_eq!(conflict_body["cancel_endpoint"].as_str(), Some(status_endpoint)); assert!( conflict_body["scope_key"] .as_str() .is_some_and(|scope_key| !scope_key.is_empty()), "conflict response must expose a readable active scope key: {}", conflict.1.1 ); let status = read_manual_transition_job_status_endpoint(&hot, conflict.0, status_endpoint).await?; assert_eq!(status["job_id"].as_str(), Some(job_id)); assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint)); let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?; assert_eq!(terminal["job_id"].as_str(), Some(job_id)); assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(terminal["prefix"].as_str(), Some(prefix)); assert_eq!(terminal["dry_run"].as_bool(), Some(false)); let terminal_status = terminal["status"].as_str(); assert!( matches!(terminal_status, Some("partial" | "unknown")), "small transition queue should surface terminal backpressure: {terminal}" ); if terminal_status == Some("unknown") { let failure_reason = terminal["failure_reason"] .as_str() .ok_or_else(|| format!("unknown terminal status omitted failure_reason: {terminal}"))?; assert!( failure_reason.contains("worker result was not persisted before the transition queue drained"), "unknown terminal status should identify lost worker-result persistence: {terminal}" ); } let skipped_queue_full = terminal["report"]["skipped_queue_full"] .as_u64() .ok_or_else(|| format!("terminal status omitted report.skipped_queue_full: {terminal}"))?; assert!( skipped_queue_full > 0, "terminal status should include queue-full backpressure counters: {terminal}" ); let queue_snapshot = terminal["queue_snapshot"] .as_object() .ok_or_else(|| format!("terminal status omitted queue_snapshot: {terminal}"))?; for field in [ "queue_capacity", "queued", "active", "workers", "queue_full", "queue_send_timeout", ] { assert!( queue_snapshot.get(field).and_then(serde_json::Value::as_u64).is_some(), "queue_snapshot.{field} must be readable in terminal status: {terminal}" ); } assert!( cold_tier_object_count(&cold_client).await? < 64, "queue pressure should leave at least one object untransitioned" ); Ok(()) } #[tokio::test] #[serial] #[ignore = "manual #1508 evidence harness: starts a 4-node cluster, a remote tier, and an in-flight transition job"] async fn four_node_manual_transition_rollout_non_empty_restart_readback() -> TestResult { init_logging(); let mut cold = RustFSTestEnvironment::new().await?; cold.access_key = "inline1508coldadmin".to_string(); cold.secret_key = "inline1508coldsecret".to_string(); cold.start_rustfs_server_without_cleanup(vec![]).await?; let cold_client = cold.create_s3_client(); cold_client.create_bucket().bucket(TIER_BUCKET).send().await?; let mut hot = RustFSTestClusterEnvironment::new(4).await?; hot.set_env("RUSTFS_SCANNER_ENABLED", "false"); hot.set_env("RUSTFS_SCANNER_CYCLE", "3600"); hot.set_env("RUSTFS_MAX_TRANSITION_WORKERS", "2"); hot.set_env("RUSTFS_TRANSITION_QUEUE_CAPACITY", "64"); hot.start().await?; let hot_client = hot.create_s3_client(0)?; let tier_name = unique_tier_name(); add_rustfs_tier(&hot, &cold, &tier_name).await?; let bucket = format!("manual-transition-1508-{}", Uuid::new_v4().simple()); let prefix = "transition/manual-rollout/"; hot_client.create_bucket().bucket(&bucket).send().await?; put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?; for index in 0u8..24 { let key = format!("{prefix}object-{index:02}.bin"); hot_client .put_object() .bucket(&bucket) .key(key) .body(ByteStream::from(payload(64 * KIB, index))) .send() .await?; } let (accepted_status, accepted_body) = start_manual_transition_job_on_node(&hot, 1, &bucket, prefix, &tier_name, false, 24).await?; assert_eq!( accepted_status, StatusCode::ACCEPTED, "non-empty #1508 transition job should be accepted by a rollout node: {}", compact_body(&accepted_body) ); let accepted: serde_json::Value = serde_json::from_str(&accepted_body)?; let job_id = accepted["job_id"] .as_str() .ok_or_else(|| format!("accepted #1508 response omitted job_id: {accepted_body}"))?; let status_endpoint = accepted["status_endpoint"] .as_str() .ok_or_else(|| format!("accepted #1508 response omitted status_endpoint: {accepted_body}"))?; let terminal = wait_for_manual_transition_job_terminal(&hot, 0, job_id, false).await?; assert_eq!(terminal["job_id"].as_str(), Some(job_id)); assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str())); assert_eq!(terminal["prefix"].as_str(), Some(prefix)); assert_eq!(terminal["dry_run"].as_bool(), Some(false)); assert_eq!( terminal["status"].as_str(), Some("completed"), "non-empty #1508 rollout transition should complete before restart readback: {terminal}" ); let transition_failed: u64 = terminal["report"] .get("transition_failed") .and_then(serde_json::Value::as_u64) .unwrap_or_default(); assert_eq!( transition_failed, 0, "terminal #1508 report should not hide transition failures: {terminal}" ); assert_eq!( terminal["report"]["tier_failure"].as_u64(), Some(0), "terminal #1508 report should not hide tier failures: {terminal}" ); let transition_completed = terminal["report"]["transition_completed"] .as_u64() .ok_or_else(|| format!("terminal #1508 report omitted transition_completed: {terminal}"))?; assert!( transition_completed > 0, "terminal #1508 report should include real completed transitions: {terminal}" ); assert!( cold_tier_object_count(&cold_client).await? > 0, "cold tier should contain transitioned #1508 objects" ); hot.stop_node(2)?; hot.start_node(2).await?; let after_restart = read_manual_transition_job_status_endpoint(&hot, 2, status_endpoint).await?; assert_eq!(after_restart["job_id"].as_str(), Some(job_id)); assert_eq!( after_restart["status"], terminal["status"], "terminal #1508 status changed after rollout node restart" ); assert_eq!( after_restart["report"]["transition_completed"], terminal["report"]["transition_completed"], "terminal #1508 transition count changed after rollout node restart" ); Ok(()) } #[tokio::test] #[serial] async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_transition() -> TestResult { init_logging(); let mut cold = RustFSTestEnvironment::new().await?; cold.access_key = "inlinecoldadmin".to_string(); cold.secret_key = "inlinecoldsecret".to_string(); cold.start_rustfs_server_without_cleanup(vec![]).await?; let cold_client = cold.create_s3_client(); cold_client.create_bucket().bucket(TIER_BUCKET).send().await?; let collector = OtlpMetricCollector::start().await?; let mut hot = RustFSTestClusterEnvironment::new(4).await?; configure_mixed_msgpack_cluster(&mut hot, &collector)?; hot.set_env("RUSTFS_SCANNER_CYCLE", "1"); hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1"); let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]); hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key); hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true"); hot.start().await?; let hot_client = hot.create_s3_client(0)?; let decode_before = collector.msgpack_json_decode_totals().await; let fallback_before = collector.msgpack_json_fallback_totals().await; let decode_errors_before = collector.msgpack_json_decode_error_totals().await; let tier_name = unique_tier_name(); add_rustfs_tier(&hot, &cold, &tier_name).await?; let bucket = "inline-transitioned-mixed-msgpack-controls"; 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"; 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!( cold_tier_object_count(&cold_client).await? >= 1, "cold-tier bucket must hold transitioned objects" ); assert_reader_path( &collector, &hot_client, ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), REMOTE_TRANSITION, REMOTE), ) .await?; assert_part_number_reader_path( &collector, &hot_client, PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), REMOTE, REMOTE_TRANSITION), ) .await?; assert_msgpack_decode_observed(&collector, &decode_before).await?; let encrypted_key = "transition/encrypted-sse.bin"; let encrypted_body = payload(16 * KIB, 0xAB); let encrypted_put = hot_client .put_object() .bucket(bucket) .key(encrypted_key) .server_side_encryption(ServerSideEncryption::Aes256) .body(ByteStream::from(encrypted_body.clone())) .send() .await?; let encrypted_head = hot_client.head_object().bucket(bucket).key(encrypted_key).send().await?; assert_eq!( encrypted_head.server_side_encryption(), Some(&ServerSideEncryption::Aes256), "HEAD must preserve SSE-S3 metadata for {encrypted_key}" ); assert_reader_path( &collector, &hot_client, ReaderPathExpectation::with_any_size_bucket( ReaderObject::new(bucket, encrypted_key, &encrypted_body, encrypted_put.e_tag(), None), LEGACY_DUPLEX, ENCRYPTED, ), ) .await?; let compressed_key = "transition/compressed-repeated.txt"; let compressed_body = compressible_payload(64 * KIB); let compressed_put = hot_client .put_object() .bucket(bucket) .key(compressed_key) .body(ByteStream::from(compressed_body.clone())) .send() .await?; assert_reader_path( &collector, &hot_client, ReaderPathExpectation::with_any_size_bucket( ReaderObject::new(bucket, compressed_key, &compressed_body, compressed_put.e_tag(), None), LEGACY_DUPLEX, COMPRESSED, ), ) .await?; assert_msgpack_fallback_unchanged(&collector, &fallback_before, &MSGPACK_FALLBACK_CONTROL_SERIES).await?; assert_msgpack_decode_errors_unchanged(&collector, &decode_errors_before, &MSGPACK_FALLBACK_CONTROL_SERIES).await?; Ok(()) } #[tokio::test] #[serial] async fn four_node_transitioned_inline_fallback() -> TestResult { init_logging(); let mut cold = RustFSTestEnvironment::new().await?; cold.access_key = "inlinecoldadmin".to_string(); cold.secret_key = "inlinecoldsecret".to_string(); cold.start_rustfs_server_without_cleanup(vec![]).await?; let cold_client = cold.create_s3_client(); cold_client.create_bucket().bucket(TIER_BUCKET).send().await?; let collector = OtlpMetricCollector::start().await?; let mut hot = RustFSTestClusterEnvironment::new(4).await?; configure_reader_metric_cluster(&mut hot, &collector); hot.set_env("RUSTFS_SCANNER_CYCLE", "1"); hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1"); hot.start().await?; let hot_client = hot.create_s3_client(0)?; let tier_name = unique_tier_name(); add_rustfs_tier(&hot, &cold, &tier_name).await?; let bucket = "inline-transitioned-fallback"; hot_client.create_bucket().bucket(bucket).send().await?; put_lifecycle_with_transition_retry(&hot_client, bucket, &tier_name).await?; let key = "transition/two-part.bin"; let (body, _, etag) = put_two_part_multipart(&hot_client, bucket, key).await?; wait_for_transition(&hot_client, bucket, key, &tier_name).await?; assert!( cold_tier_object_count(&cold_client).await? >= 1, "cold-tier bucket must hold the transitioned object" ); assert_reader_path( &collector, &hot_client, ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), REMOTE_TRANSITION, REMOTE), ) .await?; Ok(()) }