mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-02 03:19:19 +00:00
6e6b38ad8e
Co-authored-by: heihutu <heihutu@gmail.com>
2439 lines
91 KiB
Rust
2439 lines
91 KiB
Rust
// 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<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
|
|
type MetricPointVersions = BTreeMap<u64, (u64, u64)>;
|
|
type MetricValues = Arc<Mutex<BTreeMap<String, MetricPointVersions>>>;
|
|
|
|
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<Self> {
|
|
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<u64, u64> {
|
|
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<String, u64> {
|
|
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<String, u64> {
|
|
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<String, u64> {
|
|
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<String, u64> {
|
|
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<String, u64> {
|
|
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<u64> {
|
|
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<Incoming>,
|
|
values: MetricValues,
|
|
decode_values: MetricValues,
|
|
fallback_values: MetricValues,
|
|
decode_error_values: MetricValues,
|
|
) -> Result<Response<Full<Bytes>>, 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<Full<Bytes>> {
|
|
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<String, MetricPointVersions>) {
|
|
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<String, MetricPointVersions>) {
|
|
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<String, MetricPointVersions>) {
|
|
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<String, MetricPointVersions>) {
|
|
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<String, MetricPointVersions>) {
|
|
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<F>(
|
|
export: &ExportMetricsServiceRequest,
|
|
counter_name: &str,
|
|
values: &mut BTreeMap<String, MetricPointVersions>,
|
|
metric_key: F,
|
|
) where
|
|
F: Fn(&[KeyValue]) -> Option<String>,
|
|
{
|
|
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<String, u64>,
|
|
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<String, u64>) -> 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::<u64>();
|
|
let after_total = after
|
|
.iter()
|
|
.filter(|(key, _)| key.starts_with(&prefix) && key.ends_with(&suffix))
|
|
.map(|(_, value)| *value)
|
|
.sum::<u64>();
|
|
(after_total <= before_total).then_some(format!("{direction}/{}", MSGPACK_CODEC_MSGPACK))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
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<String, u64>,
|
|
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<BoundaryCase> {
|
|
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<u8> {
|
|
(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<u8> {
|
|
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<u8>, Option<String>, Option<String>)> {
|
|
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<u8>, Vec<u8>, Option<String>)> {
|
|
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::<http::Uri>()?;
|
|
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<String> {
|
|
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<Vec<TierNodeReadiness>> {
|
|
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::<serde_json::Value>(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::<Vec<_>>()
|
|
.join("; ")
|
|
}
|
|
|
|
fn compact_body(body: &str) -> String {
|
|
const MAX_BODY_CHARS: usize = 1024;
|
|
let mut value = body.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if value.chars().count() > MAX_BODY_CHARS {
|
|
value = value.chars().take(MAX_BODY_CHARS).collect::<String>();
|
|
value.push_str("...");
|
|
}
|
|
value
|
|
}
|
|
|
|
fn is_retryable_tier_error(response: &str) -> bool {
|
|
response.contains("<Code>TierNotFound</Code>")
|
|
|| response.contains("<Code>NoSuchTier</Code>")
|
|
|| (response.contains("<Code>TierVerificationFailed</Code>")
|
|
&& 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<String> {
|
|
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<serde_json::Value> {
|
|
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<serde_json::Value> {
|
|
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<LifecycleRule> {
|
|
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<usize> {
|
|
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("<Code>NoSuchKey</Code>") || 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(())
|
|
}
|