mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-13 16:11:56 +00:00
641c4b3493
* fix(ci): include pagination regression in full E2E selection * fix(deps): replace yanked yoke-derive release * fix(scanner): expose pause backlog replica diagnostics (#8258) * fix(scanner): expose pause backlog replica diagnostics Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * fix(connect): stabilize runtime profile lease cancellation Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(connect): tolerate delayed schedule startup in CI Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(e2e): retry quota reads during usage warmup Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * fix(ecstore): avoid meta-bucket incarnation self-deadlock Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * fix(test): use persisted incarnation in heal fixture Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(usage): reconcile stale counters after lifecycle expiration (#8108) * fix(usage): reconcile stale counters after lifecycle expiration * fix(usage): account lifecycle expiry during continuous writes * test(usage): run lifecycle usage scenarios on one scanner store * test(usage): use a Windows-representable pre-mutation offset * fix(usage): harden expiry accounting recovery and quota checks Borrow expiry receipt bucket names and avoid allocating a map key on cache hits. Cover cancelled receipts, durable snapshot recovery, and legacy quota admission after scanner confirmation. Use representable timestamp offsets in the quota regression. Refs rustfs/backlog#2689 Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(usage): recover stale persisted counts through the scanner Seed incorrect complete usage for empty and retained-object buckets, then run the real scanner and publication consumer without further object mutations. Verify durable and admin usage over two cycles instead of writing a corrected snapshot in the test. Refs rustfs/backlog#2689 Refs rustfs/backlog#2691 Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(e2e): bound delimiter pagination fixture concurrency The 120-second smoke timeout expired after 1018 of 1200 serial fixture PUTs, before LIST ran. Prepare the same objects with at most eight concurrent requests and await every PUT. Retain the timeout and strengthen exact prefix, KeyCount, empty Contents, and continuation-token assertions with phase diagnostics. Refs rustfs/backlog#2689 Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(upgrade): establish a persisted previous-release baseline Seed the pinned previous-release cluster and restart it once with its data intact before replacing any node. Require every old writer to pass the strict readiness probe and preserve the seed through both mixed phases and the final current cluster. Keep InternalError fail-fast behavior and all existing compatibility deadlines and assertions. Refs rustfs/backlog#2689 Refs rustfs/backlog#2384 Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(ecstore): bound cancellation metadata persistence waits Use the system-bucket incarnation boundary now supplied by main PR #8268. Bound the three cancellation waits that previously hung during pool.bin persistence, retaining their remote-generation, target-cohort, and durable-state assertions. Refs rustfs/backlog#2697 Refs rustfs/backlog#2689 Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: Chris <anzhengchao@gmail.com> Co-authored-by: Hauser <housemecn@gmail.com> Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * test(e2e): retain startup and shutdown failure diagnostics * fix(test): supply CPU workload for sampler regression * fix(ci): locate security chain scripts in the workspace * fix(usage): recover historical counters with generation fencing (#8273) * fix(usage): recover historical counters during continued writes Use newer converged scanner snapshots to reconcile stale absolute usage baselines while preserving concurrent mutation and expiry receipt fences. Cover durable publication, admin and quota reads, and legacy generations. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * refactor(usage): fence snapshots and move preserved cache entries Apply the cached scanner generation floor before every reconciliation path and retain it even when an older snapshot happens to match core counts. Move preserved usage entries instead of cloning their histogram maps under the cache lock, retaining expiry receipt identity and cancellation fences. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * test(scanner): verify checkpoint takeover and repair dispatch (#8275) * test(scanner): cover checkpoint handoff and repair dispatch Drive runtime budget expiry, partial-cycle persistence, leadership claims, and stale checkpoint rejection between real disk-backed fixture scans. Verify that a metadata repair beyond the first bounded prefix is saved in the scanner ledger and dispatched by the MRF consumer. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * ci: isolate scanner fixtures and refresh full e2e membership Reserve nextest capacity for the real-disk scanner publication and MRF admission fixtures. Bind both platform membership checks to the reviewed pagination deadline test added on main. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * refactor(scanner): consolidate checkpoint fixture lifecycle Keep one durable control store across timeout and leadership transitions, and inject generation advancement into the shared checkpoint scenario. Check the actual saved metadata path so late-write rejection also proves that existing checkpoint bytes remain intact. Centralize MRF fixture isolation and reuse nextest process isolation when the startup environment already satisfies the test contract. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(obs): distinguish allocator counters from live memory (#8274) * fix(obs): distinguish allocator counters from live memory Preserve count/counter semantics and mark requested-byte attribution unavailable when live statistics or sampling are missing. Document sustained multipart memory diagnosis. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * refactor(obs): parse allocator statistics from one node Resolve each statistic before interpreting its shape, avoiding unsupported-field tree scans and mixing data across wrapper scopes. Preserve unavailable-statistics policy and add precedence regressions. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * test(ecstore): isolate late parity recovery from metadata hedges Use the existing object-scoped hedge timer barrier in exact-count recovery fixtures. Preserve payload and total-read assertions and verify that the omitted parity disk is read only during late refresh. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(usage): combine identical snapshot retention branches * fix(test): await HTTP sender readiness in Top RPC fixture * [release/1.0.1] Gate multipart copy through write admission (#8284) Gate multipart copy through write admission Make UploadPartCopy acquire the shared foreground write admission permit before lifecycle locks or source readers so server-side multipart copy cannot bypass the same backpressure used by UploadPart. Document the shared queue semantics and add focused coverage for saturation, cancellation, lock ordering, and disabled admission. Co-authored-by: zhi22915 <qiuzgang@gmail.com> * Gate multipart copy through write admission (#8283) Make UploadPartCopy acquire the shared foreground write admission permit before lifecycle locks or source readers so server-side multipart copy cannot bypass the same backpressure used by UploadPart. Document the shared queue semantics and add focused coverage for saturation, cancellation, lock ordering, and disabled admission. Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix: add UploadPart OOM validation guardrails (#8287) * docs(release): validate candidates on release branch * fix(scanner): validate checkpoints against global cycle fence (#8278) ## Related Issues Related to rustfs/backlog#2701. ## Summary of Changes Route scanner checkpoint cycle and leader validation through the global store while retaining the owning set for cache persistence, CAS revisions and publication admission. ## Verification Two independent final-diff source reviews found no issues across correctness, concurrency and durability, test coverage, compatibility, performance and simplicity on head `8b8fe51d092090b053f552ae283960e2e306be33`. Root approval `5373624714` is bound to that exact head. Regression tests cover real two-pool routing, stale fences, post-save rejection and CAS conflicts; their reported local execution belongs to the PR author, not this merge operation. Current required CI remains pending, and this authorized admin squash does not establish CI or runtime acceptance. ## Impact Restores checkpoint progress when global cycle and leader state differ from a set-scoped view. No format, retry, timeout, assertion or scanner-policy changes are introduced by this diff. The three prior main scanner failures remain unproved repaired. ## Additional Notes Full validation must run on the resulting exact main revision. Reverting this patch restores the earlier set-scoped fence lookup and its checkpoint rejection behavior. * fix(ci): restore E2E membership and pagination timeouts (#8281) * ci: locate the auto-testing checkout for lanes that run evidence from a subdirectory (#8279) ## Related Issues Follow-up to #8229. ## Summary of Changes Locate the private auto-testing checkout from the lane root or the workspace root so the nested security checkout can record functional-chain evidence. ## Verification The exact PR headb66129ab9fpassed one mechanical correctness and simplicity review, nine real-Git layout and provenance checks, and sixteen existing evidence/envelope tests. The baseline sibling layout failed with git exit 128; the corrected layout succeeded while revision mismatches and missing checkouts stayed rejected. Current PR checks are completed with successful or skipped conclusions, including the aggregate. ## Impact Both lane and private-script revision checks remain intact. No time limits, assertions, production behavior, or evidence validation requirements change. The synthetic layout checks do not execute the actual scheduled security suite; integrated main CI and release acceptance remain separate gates. ## Additional Notes Approved review 5374559393 is bound to the exact head above. Reverting the single-file change restores the previous checkout lookup. * fix: add UploadPart OOM validation guardrails Add a Docker validation harness for backlog#2704 so the ordinary UploadPart low-concurrency memory workload can be reproduced with comparable case metadata, process/cgroup sampling, TLS and metrics toggles, cache-env controls, and write reclaim/direct-write experiments. Warn when operators set the unrecognized RUSTFS_OBJECT_CACHE_* variables that appeared in the reporter compose file. The variables are reported but remain ignored, so startup does not silently change object data cache behavior. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: overtrue <anzhengchao@gmail.com> Co-authored-by: AL <allan.bednarowski@gmail.com> Co-authored-by: hector <42570491+majinghe@users.noreply.github.com> Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(scanner): pass fence store to checkpoint fixture * fix(s3): queue bucket operations and restore strict Clippy checks (#8290) * fix(s3): queue concurrent bucket creation and deletion Keep eight active bucket transactions and bound admission waiting to 128 requests and 30 seconds. Preserve detached transaction ownership and return Retry-After with overload responses. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * fix(ecstore): restore strict Clippy compatibility on Rust 1.99 Use try_update without changing atomic ordering or overflow behavior. Keep recursive storage futures boxed once at each frame and remove the redundant async-recursion macro, including its non-recursive SQL planner use. Remove needless closure borrows and orphaned dependency entries. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * chore: refresh dependencies and atomic update APIs Update workspace dependencies and the lockfile. Replace deprecated atomic fetch_update aliases with try_update while preserving closures and memory ordering. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * improve * fix(scanner): diagnose and verify pause backlog recovery (#8293) * fix(scanner): diagnose and verify pause backlog recovery Expose the retained replica snapshot and claimed membership in abnormal admin status responses. Keep diagnostics off metrics updates and verify single-pool recovery and conflicting-proof preservation across 24 sets. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * refactor(scanner): move replica snapshots into diagnostics Consume the terminal admin read snapshot in a single state match and move membership, revision, and error buffers into the response. Verify buffer handoff and the unchanged JSON contract without altering ledger authority. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(heal): wait for held legacy responsibility in replay test * ci: remove Docker Hub description sync * fix: emit NextPartNumberMarker only when ListParts is truncated ListPartsInfo.next_part_number_marker was a non-optional usize that defaulted to 0 and was only assigned when the response was truncated. The S3 serializer then emitted it unconditionally as Some(0), causing AWS SDK paginators to loop infinitely on part_number_marker=0 instead of terminating. Change the field to Option<usize> (None by default) and set it only inside the is_truncated branch. The S3 output layer now uses .and_then() so NextPartNumberMarker is absent when IsTruncated=false, matching AWS S3 behavior. Fixes #8208 (cherry picked from commit44de803a38) * fix(s3): honor sparse ListParts markers and verify termination Resume part listings at the first part above the numeric marker, even when that marker is absent. Use binary search over the sorted part numbers and retain the existing exact-tail empty-slice path. Add storage, XML, and real AWS SDK paginator regressions for empty and terminal pages, sparse markers, and multipart completion integrity. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> (cherry picked from commit673031eea1) * fix(storage): publish delete rollback backups atomically Stage rollback metadata outside the rollback directory and publish it only after the full write succeeds. A short write must not leave a backup that quorum rollback can rename over acknowledged version history. Add an isolated real short-write regression and register the backported ListParts SDK test in the smoke and Linux full inventories. * test(e2e): register paginator regression in Darwin inventory * fix(release): install yq before Helm template checks * chore(release): align installation references for 1.0.1 --------- Co-authored-by: Hauser <housemecn@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> Co-authored-by: Peder Bergan <pederbe@users.noreply.github.com> Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: AL <allan.bednarowski@gmail.com> Co-authored-by: hector <42570491+majinghe@users.noreply.github.com> Co-authored-by: Chapman <touch65536@gmail.com>
2654 lines
102 KiB
Rust
2654 lines
102 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), and the GET fast path follows the persisted
|
|
//! inline marker. 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};
|
|
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 bytes::Bytes;
|
|
use flate2::read::GzDecoder;
|
|
use http::header::CONTENT_ENCODING;
|
|
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 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";
|
|
/// Physical bytes the erasure layer pulled from disk, emitted per shard read by
|
|
/// `crates/ecstore/src/erasure/coding/decode.rs`.
|
|
const SHARD_READ_BYTES_COUNTER: &str = "rustfs_io_get_object_shard_read_observed_bytes_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 ALLOW_LOOPBACK_TIER_ENDPOINT_ENV: &str = "RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT";
|
|
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,
|
|
shard_read_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 shard_read_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_shard_read_values = shard_read_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();
|
|
let shard_read_values = task_shard_read_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(),
|
|
shard_read_values.clone(),
|
|
)
|
|
}),
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
});
|
|
Ok(Self {
|
|
endpoint,
|
|
values,
|
|
decode_values,
|
|
fallback_values,
|
|
decode_error_values,
|
|
shard_read_values,
|
|
task,
|
|
})
|
|
}
|
|
|
|
/// Total physical bytes read from disk across every shard-read label set.
|
|
async fn shard_read_bytes_total(&self) -> u64 {
|
|
self.shard_read_values
|
|
.lock()
|
|
.await
|
|
.values()
|
|
.map(|versions| versions.values().map(|(_, value)| *value).sum::<u64>())
|
|
.sum()
|
|
}
|
|
|
|
/// Waits until the shard-read counter stops advancing so a measurement window
|
|
/// is not polluted by exports still in flight.
|
|
///
|
|
/// Requires several consecutive equal samples spanning more than one export
|
|
/// interval (`RUSTFS_OBS_METER_INTERVAL=1`): a single unchanged sample only
|
|
/// proves the latest export has not landed yet, which silently reads as "no
|
|
/// disk reads happened" and makes any upper-bound assertion vacuous.
|
|
async fn wait_for_shard_read_bytes_to_settle(&self) -> TestResult<u64> {
|
|
const REQUIRED_STABLE_SAMPLES: usize = 5;
|
|
let mut last = self.shard_read_bytes_total().await;
|
|
let mut stable = 0;
|
|
for _ in 0..60 {
|
|
sleep(Duration::from_millis(500)).await;
|
|
let current = self.shard_read_bytes_total().await;
|
|
if current == last {
|
|
stable += 1;
|
|
if stable >= REQUIRED_STABLE_SAMPLES {
|
|
return Ok(current);
|
|
}
|
|
} else {
|
|
stable = 0;
|
|
last = current;
|
|
}
|
|
}
|
|
Err("timed out waiting for shard-read byte counter to settle".into())
|
|
}
|
|
|
|
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,
|
|
shard_read_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;
|
|
let mut shard_read_values = shard_read_values.lock().await;
|
|
record_reader_path_metrics(&export, &mut values);
|
|
record_shard_read_bytes_metrics(&export, &mut shard_read_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}")
|
|
}
|
|
|
|
/// Accumulates `SHARD_READ_BYTES_COUNTER` across all label sets. Only the total
|
|
/// matters: it is the number of physical bytes the erasure layer actually pulled
|
|
/// from disk, which is what separates a bounded per-part read from a decode of
|
|
/// the whole object.
|
|
fn record_shard_read_bytes_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 {
|
|
if metric.name != SHARD_READ_BYTES_COUNTER {
|
|
continue;
|
|
}
|
|
let Some(metric::Data::Sum(sum)) = &metric.data else {
|
|
continue;
|
|
};
|
|
for point in &sum.data_points {
|
|
let Some(number_data_point::Value::AsInt(value)) = point.value.as_ref() else {
|
|
continue;
|
|
};
|
|
let value = u64::try_from(*value).unwrap_or_default();
|
|
// Keyed by labels, not by position: point order within an export
|
|
// is not guaranteed stable, so an index key would alias distinct
|
|
// series across batches.
|
|
let key = format!(
|
|
"{}\u{1f}{}\u{1f}{}",
|
|
attribute_string(&point.attributes, "path").unwrap_or_default(),
|
|
attribute_string(&point.attributes, "role").unwrap_or_default(),
|
|
attribute_string(&point.attributes, "outcome").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 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 storage_limit = match state {
|
|
VersionState::Enabled => 32 * KIB,
|
|
VersionState::Unversioned => 256 * KIB,
|
|
// A suspended bucket stores its null version using the unversioned
|
|
// shard threshold, while ObjectInfo keeps version-aware GET semantics.
|
|
VersionState::Suspended => 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 <= storage_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");
|
|
// Keep startup diagnostics available when OTLP log export is disabled.
|
|
cluster.set_env("RUSTFS_OBS_LOG_STDOUT_ENABLED", "true");
|
|
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)?;
|
|
// A suspended PUT omits the response version, but reads identify the
|
|
// stored null version explicitly.
|
|
let read_version_id = match state {
|
|
VersionState::Suspended => Some("null".to_owned()),
|
|
_ => version_id,
|
|
};
|
|
Ok((key, body, put.e_tag().map(str::to_owned), read_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_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)))
|
|
}
|
|
|
|
/// Thin wrapper over [`crate::common::admin_request`], kept local so the call
|
|
/// sites below keep their `Option<&str>` body shape.
|
|
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)> {
|
|
crate::common::admin_request(base_url, method, path, body.map(str::to_string), access_key, secret_key).await
|
|
}
|
|
|
|
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]
|
|
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);
|
|
// Inspect every disk only after the PUT rename fanout has drained.
|
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
|
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]
|
|
async fn four_node_empty_legacy_volumes_start_as_fresh() -> TestResult {
|
|
init_logging();
|
|
|
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
|
for data_dir in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
|
tokio::fs::create_dir_all(Path::new(data_dir).join(".minio.sys")).await?;
|
|
}
|
|
|
|
cluster.start().await?;
|
|
|
|
// Starting is not the assertion. The regression is that an empty legacy
|
|
// `.minio.sys` must be classified as a *fresh* volume, not as an existing
|
|
// MinIO deployment to adopt or migrate. Pin what that classification leaves
|
|
// on disk and in the namespace.
|
|
let buckets = cluster.create_s3_client(0)?.list_buckets().send().await?;
|
|
assert!(
|
|
buckets.buckets().is_empty(),
|
|
"a fresh classification must not adopt buckets from the pre-existing directories, got {:?}",
|
|
buckets.buckets().iter().filter_map(|b| b.name()).collect::<Vec<_>>()
|
|
);
|
|
|
|
for data_dir in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
|
assert!(
|
|
Path::new(data_dir).join(".rustfs.sys").join("format.json").is_file(),
|
|
"each drive must be formatted as fresh: {data_dir} has no .rustfs.sys/format.json"
|
|
);
|
|
let mut legacy = tokio::fs::read_dir(Path::new(data_dir).join(".minio.sys")).await?;
|
|
assert!(
|
|
legacy.next_entry().await?.is_none(),
|
|
"the empty legacy directory must be left untouched, not migrated into: {data_dir}"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
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_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
|
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key);
|
|
// Inspect every disk only after the PUT rename fanout has drained.
|
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
|
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?;
|
|
// 16 KiB of plaintext is 8 KiB per data shard on EC 2+2, inside the inline
|
|
// budget: an SSE-S3 PUT is admitted inline by its plaintext size even though
|
|
// the ciphertext length is unknown up front (backlog#2393 STOR-115).
|
|
assert_storage_layout(&cluster, bucket, encrypted_key, None, true)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
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");
|
|
// Inspect every disk only after the PUT rename fanout has drained.
|
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
|
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::for_class(ReaderObject::new(bucket, key, &body, put.e_tag(), None), LEGACY_DUPLEX, COMPRESSED),
|
|
)
|
|
.await?;
|
|
// 64 KiB of plaintext is 32 KiB per data shard on EC 2+2, inside the inline
|
|
// budget: a compressed PUT is admitted inline by its plaintext size even
|
|
// though its stored size is unknown up front (backlog#2393 STOR-115).
|
|
assert_storage_layout(&cluster, bucket, key, None, true)?;
|
|
|
|
// A server-side copy onto another compressible key re-compresses the
|
|
// decompressed source stream and lands inline the same way (STOR-125).
|
|
let copy_key = "compressed/copy.txt";
|
|
let copy = client
|
|
.copy_object()
|
|
.bucket(bucket)
|
|
.key(copy_key)
|
|
.copy_source(format!("{bucket}/{key}"))
|
|
.send()
|
|
.await?;
|
|
let copy_etag = copy.copy_object_result().and_then(|result| result.e_tag()).map(str::to_owned);
|
|
assert_reader_path(
|
|
&collector,
|
|
&client,
|
|
ReaderPathExpectation::for_class(
|
|
ReaderObject::new(bucket, copy_key, &body, copy_etag.as_deref(), None),
|
|
LEGACY_DUPLEX,
|
|
COMPRESSED,
|
|
),
|
|
)
|
|
.await?;
|
|
assert_storage_layout(&cluster, bucket, copy_key, None, true)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Multipart disk compression is live again, so a compression-enabled cluster classifies multipart objects as compressed and the roundtrip (full GET plus partNumber GET) must still return the original bytes.
|
|
/// Reverting the multipart compression fix must fail this test.
|
|
#[tokio::test]
|
|
async fn four_node_multipart_disk_compression_roundtrip() -> 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.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
|
cluster.start().await?;
|
|
|
|
let bucket = "inline-multipart-compression-roundtrip";
|
|
cluster.create_test_bucket(bucket).await?;
|
|
let client = cluster.create_s3_client(0)?;
|
|
let key = "multipart/compressed.txt";
|
|
let (body, second_part, etag) = put_two_part_multipart(&client, bucket, key).await?;
|
|
|
|
assert_reader_path(
|
|
&collector,
|
|
&client,
|
|
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, COMPRESSED),
|
|
)
|
|
.await?;
|
|
assert_part_number_reader_path(
|
|
&collector,
|
|
&client,
|
|
PartNumberReaderPathExpectation::new(bucket, key, &second_part, body.len(), COMPRESSED, LEGACY_DUPLEX),
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A tail range over a compressed multipart object must read only the physical
|
|
/// data it needs, not decode the object from byte zero.
|
|
///
|
|
/// The byte-exactness tests around this one stay green even if the seek path
|
|
/// regresses into decoding from the start of the object: the bytes returned are
|
|
/// still correct, only the read amplification explodes. This asserts the cost
|
|
/// side, using `SHARD_READ_BYTES_COUNTER` — already emitted per shard read by the
|
|
/// erasure layer, so no production code is instrumented for the test.
|
|
///
|
|
/// `get_compressed_offsets` skips whole preceding parts by their stored size and
|
|
/// then seeks inside the covering part via its compression index, so a bounded
|
|
/// read costs on the order of the covering part's block size against a ~5 MiB
|
|
/// object.
|
|
#[tokio::test]
|
|
async fn four_node_compressed_multipart_tail_range_reads_are_bounded() -> 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.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
|
cluster.start().await?;
|
|
|
|
let bucket = "inline-multipart-compression-tail-range";
|
|
cluster.create_test_bucket(bucket).await?;
|
|
let client = cluster.create_s3_client(0)?;
|
|
let key = "multipart/tail-range.txt";
|
|
let (body, _second_part, etag) = put_two_part_multipart(&client, bucket, key).await?;
|
|
|
|
// Establish that the object really took the compressed read path; otherwise a
|
|
// small delta below would only prove compression never happened.
|
|
assert_reader_path(
|
|
&collector,
|
|
&client,
|
|
ReaderPathExpectation::for_class(ReaderObject::new(bucket, key, &body, etag.as_deref(), None), LEGACY_DUPLEX, COMPRESSED),
|
|
)
|
|
.await?;
|
|
|
|
let baseline = collector.wait_for_shard_read_bytes_to_settle().await?;
|
|
|
|
let tail_len = 4 * KIB;
|
|
let start = body.len() - tail_len;
|
|
let end = body.len() - 1;
|
|
let range = client
|
|
.get_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.range(format!("bytes={start}-{end}"))
|
|
.send()
|
|
.await?;
|
|
let tail = range.body.collect().await?.into_bytes();
|
|
assert_eq!(tail.as_ref(), &body[start..], "tail range returned wrong bytes");
|
|
|
|
let after = collector.wait_for_shard_read_bytes_to_settle().await?;
|
|
let read_bytes = after.saturating_sub(baseline);
|
|
|
|
// A zero delta means the window caught nothing — an unexported counter, or a
|
|
// read served without touching the erasure layer — which would make the upper
|
|
// bound vacuously true. Fail instead of passing blind.
|
|
assert!(
|
|
read_bytes > 0,
|
|
"no shard reads observed for the tail range; the budget assertion below would be vacuous"
|
|
);
|
|
|
|
// Part 1 alone is MPU_PART_1_SIZE, so a whole-object decode cannot come in
|
|
// under it. Half the logical size leaves generous headroom for erasure padding
|
|
// and unrelated background reads while still failing loudly on a full decode.
|
|
let budget = (body.len() / 2) as u64;
|
|
assert!(
|
|
read_bytes < budget,
|
|
"tail range read {read_bytes} physical bytes for a {tail_len}-byte range (budget {budget}, object {} bytes): \
|
|
the read is not bounded to the covering part",
|
|
body.len()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
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_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
|
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
|
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
|
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_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,
|
|
COMPRESSED,
|
|
),
|
|
)
|
|
.await?;
|
|
assert_part_number_reader_path(
|
|
&collector,
|
|
&client,
|
|
PartNumberReaderPathExpectation::new(
|
|
bucket,
|
|
multipart_key,
|
|
&second_part,
|
|
multipart_body.len(),
|
|
COMPRESSED,
|
|
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]
|
|
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.set_env(ALLOW_LOOPBACK_TIER_ENDPOINT_ENV, "true");
|
|
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]
|
|
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.set_env(ALLOW_LOOPBACK_TIER_ENDPOINT_ENV, "true");
|
|
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]
|
|
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]
|
|
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(ALLOW_LOOPBACK_TIER_ENDPOINT_ENV, "true");
|
|
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.set_env("RUSTFS_TRANSITION_QUEUE_SEND_TIMEOUT_MS", "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?;
|
|
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(1024 * KIB, index)))
|
|
.send()
|
|
.await?;
|
|
}
|
|
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).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}"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[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(ALLOW_LOOPBACK_TIER_ENDPOINT_ENV, "true");
|
|
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]
|
|
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?;
|
|
hot.set_env(ALLOW_LOOPBACK_TIER_ENDPOINT_ENV, "true");
|
|
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_simd::STANDARD.encode_to_string([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?;
|
|
|
|
// `.zip` sits on the disk-compression exclusion list: this test pins
|
|
// msgpack compat controls across ILM transition, and a compressed object
|
|
// would classify as `compressed` instead of `remote` (and the warm-tier
|
|
// read path does not decode compression — tracked separately).
|
|
let key = "transition/mixed-multipart.zip";
|
|
let (body, second_part, etag) = put_two_part_multipart(&hot_client, bucket, key).await?;
|
|
wait_for_transition(&hot_client, bucket, key, &tier_name).await?;
|
|
assert!(
|
|
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]
|
|
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?;
|
|
hot.set_env(ALLOW_LOOPBACK_TIER_ENDPOINT_ENV, "true");
|
|
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(())
|
|
}
|