Files
rustfs/crates/ecstore/src/set_disk/mod.rs
T
Zhengchao An 86eafc799b refactor(ecstore): add SetDisks borrow context for God-Object split (backlog#816) (#4265)
P0 of the SetDisks split (tracking backlog#815). Introduce SetDisksCtx<'a>,
a Copy borrow handle over the shared SetDisks core state (topology/config,
disks, locker trio), and validate the pattern by routing the List family's
delete_all through a ListOperations<'a> service unit.

No trait impl is moved and runtime behavior is byte-for-byte unchanged; the
public SetDisks::delete_all entry point is preserved and delegates to the
borrow-based unit.
2026-07-05 00:13:34 +08:00

10657 lines
404 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.
// #730: SetDisks still hosts staged read/heal/write migration helpers.
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(unused_variables)]
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::metadata_sys;
use crate::bucket::object_lock::objectlock_sys::check_retention_for_modification;
use crate::bucket::replication::{
ReplicateDecision, ReplicationObjectBridge, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
replication_state_to_filemeta,
};
use crate::bucket::versioning::VersioningApi;
use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::client::{object_api_utils::get_raw_etag, transition_api::ReaderImpl};
use crate::cluster::rpc::heal_bucket_local_on_disks;
use crate::data_usage::record_compression_total_memory;
use crate::diagnostics::get::{
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART, GET_OBJECT_PATH_BODY_CACHE, GET_OBJECT_PATH_CODEC_STREAMING,
GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE, GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE, GET_OBJECT_PATH_DIRECT_MEMORY,
GET_OBJECT_PATH_EMPTY, GET_OBJECT_PATH_INLINE_DIRECT, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_REMOTE_TRANSITION,
GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE, GET_STAGE_EMIT, GET_STAGE_INLINE_PREPARE, GET_STAGE_LOCK_ACQUIRE,
GET_STAGE_METADATA, GET_STAGE_OBJECT_INFO, GET_STAGE_PATH_DECISION, GET_STAGE_READER_SETUP, classify_storage_error,
get_stage_timer_if_enabled, record_get_object_pipeline_failure, record_get_stage_duration_if_enabled,
};
use crate::disk::error_reduce::{
BUCKET_OP_IGNORED_ERRS, OBJECT_OP_IGNORED_ERRS, build_write_quorum_failure_summary, count_errs, reduce_read_quorum_errs,
reduce_write_quorum_errs,
};
use crate::disk::{
self, CHECK_PART_DISK_NOT_FOUND, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
conv_part_err_to_int, has_part_err,
};
use crate::disk::{STORAGE_FORMAT_FILE, count_part_not_success};
use crate::erasure::codec::bridge::{
CodecStreamingDecodeEngine, GET_CODEC_STREAMING_ENGINE_LEGACY, GET_CODEC_STREAMING_ENGINE_RUSTFS,
};
use crate::erasure::coding;
use crate::error::{Error, Result, is_err_version_not_found};
use crate::error::{GenericError, ObjectApiError, is_err_object_not_found};
use crate::io_support::bitrot::{create_bitrot_reader, create_bitrot_reader_from_bytes, create_bitrot_writer};
use crate::object_api::ObjectOptions;
use crate::object_api::get_object_body_cache_hook;
use crate::runtime::sources as runtime_sources;
use crate::services::batch_processor::AsyncBatchProcessor;
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
multipart::{
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations as _, MultipartUploadResult, PartInfo,
},
namespace::NamespaceLocking as _,
object::{DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
range::HTTPRangeSpec,
};
use crate::store::utils::is_reserved_or_invalid_bucket;
use crate::{
bucket::lifecycle::bucket_lifecycle_ops::{
LifecycleOps, gen_transition_objname, get_transitioned_object_reader, put_restore_opts,
},
cache_value::metacache_set::{ListPathRawOptions, list_path_raw},
config::storageclass,
disk::{
CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, FileInfoVersions,
RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions,
UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3, new_disk,
},
error::{StorageError, to_object_err},
object_api::{GetObjectReader, ObjectInfo, PutObjReader},
// event::name::EventName,
services::event_notification::{EventArgs, send_event},
store::init_format::{get_format_erasure_in_quorum, load_format_erasure, load_format_erasure_all, save_format_file},
};
use bytes::Bytes;
use bytesize::ByteSize;
use chrono::Utc;
use futures::future::join_all;
use glob::Pattern;
use http::HeaderMap;
use md5::{Digest as Md5Digest, Md5};
use rand::{Rng, seq::SliceRandom};
use regex::Regex;
use rustfs_common::heal_channel::{
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
send_heal_disk, send_heal_request_with_admission,
};
use rustfs_config::MI_B;
use rustfs_filemeta::{
FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo,
RawFileInfo, file_info_from_raw, merge_file_meta_versions,
};
use rustfs_io_metrics::{
record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration,
record_object_lock_diag_slow_acquire, record_object_lock_diag_slow_hold,
};
use rustfs_lock::LockClient;
use rustfs_lock::fast_lock::types::LockResult;
use rustfs_lock::local_lock::LocalLock;
use rustfs_lock::{FastLockGuard, LockManager, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey};
use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos};
use rustfs_object_capacity::capacity_scope::{
CapacityScope, CapacityScopeDisk, record_capacity_scope, record_global_dirty_scope,
};
use rustfs_s3_types::EventName;
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
use rustfs_utils::http::headers::{
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
};
use rustfs_utils::http::{
SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE,
SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC, contains_key_str, get_header_map, get_str,
insert_str, is_encryption_metadata_key, remove_header_map,
};
use rustfs_utils::{
HashAlgorithm,
crypto::hex,
path::{SLASH_SEPARATOR, encode_dir_object, has_suffix, path_join_buf},
};
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, X_AMZ_RESTORE};
use sha2::{Digest, Sha256};
use std::hash::Hash;
use std::mem::{self};
use std::pin::Pin;
use std::sync::OnceLock;
use std::task::{Context, Poll};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use std::{
collections::{HashMap, HashSet},
io::{Cursor, Write},
path::Path,
sync::Arc,
time::Duration,
};
use time::OffsetDateTime;
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, ReadBuf},
sync::{RwLock, broadcast},
};
use tokio::{
select,
sync::mpsc::{self, Sender},
time::{interval, timeout},
};
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::{Instrument, debug, info, warn};
use uuid::Uuid;
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
type InlineBitrotReader = coding::BitrotReader<Box<dyn tokio::io::AsyncRead + Send + Sync + Unpin>>;
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_SET_DISK: &str = "set_disk";
const EVENT_SET_DISK_MULTIPART: &str = "set_disk_multipart";
const COMPLETE_MULTIPART_PART_MISSING: &str = "part_missing";
const COMPLETE_MULTIPART_PART_READ_QUORUM_UNAVAILABLE: &str = "read_quorum_unavailable";
const COMPLETE_MULTIPART_PART_ERROR: &str = "part_error";
const MULTIPART_WRITE_QUORUM_UPLOAD_METADATA: &str = "upload_metadata";
const MULTIPART_WRITE_QUORUM_WRITER_SETUP: &str = "writer_setup";
const MULTIPART_WRITE_QUORUM_RENAME_PART: &str = "rename_part";
const EVENT_SET_DISK_WRITE: &str = "set_disk_write";
const EVENT_SET_DISK_HEAL: &str = "set_disk_heal";
const EVENT_SET_DISK_COMMIT_TAIL_SLOW: &str = "set_disk_commit_tail_slow";
const EVENT_SET_DISK_PUT_OBJECT_STAGE_SUMMARY: &str = "set_disk_put_object_stage_summary";
const SET_DISK_COMMIT_TAIL_WARN_THRESHOLD_MS: u128 = 5_000;
const ENV_RUSTFS_PUT_LARGE_BATCH_MIN_SIZE_BYTES: &str = "RUSTFS_PUT_LARGE_BATCH_MIN_SIZE_BYTES";
const DEFAULT_RUSTFS_PUT_LARGE_BATCH_MIN_SIZE_BYTES: usize = 64 * 1024 * 1024;
static CACHED_PUT_LARGE_BATCH_MIN_SIZE_BYTES: OnceLock<usize> = OnceLock::new();
const ENV_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: &str = "RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES";
const DEFAULT_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: usize = 128 * 1024 * 1024;
static CACHED_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: OnceLock<usize> = OnceLock::new();
use crate::io_support::rio::{EtagResolvable, HashReader, HashReaderMut, TryGetIndex as _};
pub const DEFAULT_READ_BUFFER_SIZE: usize = MI_B; // 1 MiB = 1024 * 1024;
pub const MAX_PARTS_COUNT: usize = 10000;
pub(crate) const RUSTFS_MULTIPART_BUCKET_KEY: &str = "x-rustfs-internal-multipart-bucket";
pub(crate) const RUSTFS_MULTIPART_OBJECT_KEY: &str = "x-rustfs-internal-multipart-object";
const ENV_ISSUE3031_DIAG_ENABLE: &str = "RUSTFS_ISSUE3031_DIAG_ENABLE";
struct ObjectLockDiagGuard {
guard: NamespaceLockGuard,
enabled: bool,
op: &'static str,
bucket: Option<String>,
object: Option<String>,
owner: Option<String>,
mode: &'static str,
acquired_at: Instant,
}
impl ObjectLockDiagGuard {
fn new(
guard: NamespaceLockGuard,
enabled: bool,
op: &'static str,
bucket: Option<String>,
object: Option<String>,
owner: Option<String>,
mode: &'static str,
) -> Self {
Self {
guard,
enabled,
op,
bucket,
object,
owner,
mode,
acquired_at: Instant::now(),
}
}
}
impl Drop for ObjectLockDiagGuard {
fn drop(&mut self) {
if !self.enabled || self.guard.is_released() {
return;
}
let hold = self.acquired_at.elapsed();
record_object_lock_diag_hold_duration(self.op, self.mode, hold);
let threshold = get_object_lock_diag_slow_hold_threshold();
if hold >= threshold {
record_object_lock_diag_slow_hold(self.op, self.mode);
warn!(
target: "rustfs_ecstore::object_lock_diag",
op = self.op,
bucket = %self.bucket.as_deref().unwrap_or_default(),
object = %self.object.as_deref().unwrap_or_default(),
mode = self.mode,
owner = %self.owner.as_deref().unwrap_or_default(),
hold_ms = hold.as_millis(),
threshold_ms = threshold.as_millis(),
"object namespace lock held longer than threshold"
);
}
}
}
struct SetDiskLockGuardedReader {
inner: Box<dyn AsyncRead + Unpin + Send + Sync>,
guard: Option<ObjectLockDiagGuard>,
}
impl AsyncRead for SetDiskLockGuardedReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
let had_capacity = buf.remaining() > 0;
let filled_before = buf.filled().len();
let poll = Pin::new(&mut self.inner).poll_read(cx, buf);
if had_capacity && matches!(poll, Poll::Ready(Ok(()))) && buf.filled().len() == filled_before {
self.guard.take();
}
poll
}
}
fn finish_set_disk_read_lock(
mut reader: GetObjectReader,
read_lock_guard: Option<ObjectLockDiagGuard>,
lock_optimization_enabled: bool,
bucket: &str,
object: &str,
) -> GetObjectReader {
if lock_optimization_enabled || reader.buffered_body.is_some() {
release_materialized_read_lock(bucket, object, read_lock_guard);
return reader;
}
if let Some(guard) = read_lock_guard {
reader.stream = Box::new(SetDiskLockGuardedReader {
inner: reader.stream,
guard: Some(guard),
});
}
reader
}
fn release_materialized_read_lock(bucket: &str, object: &str, read_lock_guard: Option<ObjectLockDiagGuard>) {
if read_lock_guard.is_some() {
let lock_id = format!("{}:{}", bucket, object);
record_lock_release(bucket, object, &lock_id, "read");
metrics::counter!("rustfs.lock.release.early.total", "type" => "read").increment(1);
}
drop(read_lock_guard);
}
pub(crate) fn strip_internal_multipart_metadata(metadata: &mut HashMap<String, String>) {
metadata.remove(RUSTFS_MULTIPART_BUCKET_KEY);
metadata.remove(RUSTFS_MULTIPART_OBJECT_KEY);
}
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
metadata.keys().any(|key| is_encryption_metadata_key(key))
|| metadata.contains_key(SSEC_ALGORITHM_HEADER)
|| metadata.contains_key(SSEC_KEY_HEADER)
|| metadata.contains_key(SSEC_KEY_MD5_HEADER)
}
fn capacity_scope_from_disks(disks: &[Option<DiskStore>]) -> CapacityScope {
let mut unique = HashSet::with_capacity(disks.len());
let mut scoped_disks = Vec::with_capacity(disks.len());
for disk in disks.iter().flatten() {
let scope_disk = CapacityScopeDisk {
endpoint: disk.endpoint().to_string(),
drive_path: disk.to_string(),
};
if unique.insert(scope_disk.clone()) {
scoped_disks.push(scope_disk);
}
}
CapacityScope { disks: scoped_disks }
}
fn record_capacity_scope_if_needed(scope_token: Option<Uuid>, disks: &[Option<DiskStore>]) {
let scope = capacity_scope_from_disks(disks);
if scope.disks.is_empty() {
return;
}
record_global_dirty_scope(scope.clone());
if let Some(token) = scope_token {
record_capacity_scope(token, scope);
}
}
/// Get the duplex buffer size from environment variable or use default.
///
/// This function reads `RUSTFS_DUPLEX_BUFFER_SIZE` environment variable
/// to allow runtime configuration of the duplex pipe buffer size.
/// A larger buffer (e.g., 4MB) helps prevent backpressure-related hangs
/// when reading large objects (20-26MB) under high concurrency.
///
/// Default: 4MB (4 * 1024 * 1024 bytes)
/// Get duplex buffer size from environment variable.
///
/// **Deprecated**: Use `adaptive_duplex_buffer_size()` for object-size-aware sizing.
pub fn get_duplex_buffer_size() -> usize {
rustfs_utils::get_env_usize(
rustfs_config::ENV_OBJECT_DUPLEX_BUFFER_SIZE,
rustfs_config::DEFAULT_OBJECT_DUPLEX_BUFFER_SIZE,
)
}
/// Get adaptive duplex buffer size based on object size.
///
/// Smaller objects get smaller buffers to reduce memory waste.
/// Larger objects get larger buffers to prevent backpressure.
fn adaptive_duplex_buffer_size(object_size: i64) -> usize {
const KB: usize = 1024;
const MB: usize = 1024 * 1024;
match object_size {
0..=1_048_576 => 64 * KB, // <= 1MB: 64KB
1_048_577..=16_777_216 => MB, // <= 16MB: 1MB
16_777_217..=268_435_456 => 4 * MB, // <= 256MB: 4MB
_ => 8 * MB, // > 256MB: 8MB
}
}
// ============================================================================
// GET Optimization Configuration
//
// All GET performance optimization flags are consolidated here.
// Each flag uses `OnceLock` for caching — env var changes require process restart.
// Each flag has a corresponding `*_ROLLOUT_PCT` for percentage-based gradual rollout.
// ============================================================================
const DISK_ONLINE_TIMEOUT: Duration = Duration::from_secs(1);
const DISK_HEALTH_CACHE_TTL: Duration = Duration::from_millis(750);
const GET_OBJECT_METADATA_CACHE_TTL: Duration = Duration::from_secs(2); // Increased from 250ms to 2s
const DEFAULT_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES: usize = 4096; // Increased from 1024 to 4096
const ENV_RUSTFS_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES: &str = "RUSTFS_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES";
// --- Codec Streaming Configuration ---
const ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_ENABLE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENABLE: bool = false; // Disabled until rollout gates are ready
const ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE: &str = "RUSTFS_GET_CODEC_STREAMING_MIN_SIZE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE: usize = MI_B;
const ENV_RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE: &str = "RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE: usize = MI_B;
const ENV_RUSTFS_GET_CODEC_STREAMING_ENGINE: &str = "RUSTFS_GET_CODEC_STREAMING_ENGINE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENGINE: &str = GET_CODEC_STREAMING_ENGINE_LEGACY;
const ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT: &str = "RUSTFS_GET_CODEC_STREAMING_ROLLOUT";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_ROLLOUT: &str = "off";
const ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED";
const ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED: &str = "RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED";
const ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT: &str = "RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT: u32 = 100;
const ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE: bool = false;
const ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS: &str = "RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS: usize = 256;
const ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE: bool = false;
const ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE: &str = "RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE";
const DEFAULT_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE: usize = 512 * 1024;
const ENV_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY: &str = "RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY";
const DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY: bool = false;
const ENV_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD: &str = "RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD";
const DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD: usize = 128 * 1024;
// --- Metadata Early-Stop Configuration ---
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_EARLY_STOP_ENABLE";
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: bool = false;
const ENV_RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT: &str = "RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT";
const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT: u32 = 100;
const ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: &str = "RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE";
const DEFAULT_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE: bool = false;
static OBJECT_LOCK_DIAG_ENABLED: OnceLock<bool> = OnceLock::new();
mod ctx;
mod heal;
mod list;
mod lock;
#[path = "../metadata/set_disk.rs"]
mod metadata;
mod multipart;
mod read;
mod replication;
pub(crate) mod shard_source;
mod write;
/// Get lock acquire timeout from environment variable RUSTFS_LOCK_ACQUIRE_TIMEOUT (in seconds)
/// Defaults to 30 seconds if not set or invalid
/// Lock acquisition timeout. Cached: this is consulted on every object
/// lock acquisition and `std::env::var` takes a process-global lock. In test
/// builds the env var is read directly so `temp_env` overrides take effect.
pub fn get_lock_acquire_timeout() -> Duration {
#[cfg(test)]
{
Duration::from_secs(rustfs_utils::get_env_u64(
rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT,
rustfs_config::DEFAULT_OBJECT_LOCK_ACQUIRE_TIMEOUT,
))
}
#[cfg(not(test))]
{
static CACHED: OnceLock<Duration> = OnceLock::new();
*CACHED.get_or_init(|| {
Duration::from_secs(rustfs_utils::get_env_u64(
rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT,
rustfs_config::DEFAULT_OBJECT_LOCK_ACQUIRE_TIMEOUT,
))
})
}
}
pub fn is_object_lock_diag_enabled() -> bool {
*OBJECT_LOCK_DIAG_ENABLED.get_or_init(|| {
let enabled = rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_LOCK_DIAG_ENABLE,
rustfs_config::DEFAULT_OBJECT_LOCK_DIAG_ENABLE,
);
record_object_lock_diag_enabled(enabled);
enabled
})
}
pub fn get_object_lock_diag_slow_acquire_threshold() -> Duration {
Duration::from_millis(rustfs_utils::get_env_u64(
rustfs_config::ENV_OBJECT_LOCK_DIAG_SLOW_ACQUIRE_MS,
rustfs_config::DEFAULT_OBJECT_LOCK_DIAG_SLOW_ACQUIRE_MS,
))
}
pub fn get_object_lock_diag_slow_hold_threshold() -> Duration {
Duration::from_millis(rustfs_utils::get_env_u64(
rustfs_config::ENV_OBJECT_LOCK_DIAG_SLOW_HOLD_MS,
rustfs_config::DEFAULT_OBJECT_LOCK_DIAG_SLOW_HOLD_MS,
))
}
/// Check if lock optimization is enabled.
/// When enabled, fully materialized reads may release the read lock before
/// returning to the caller. Streaming reads keep the lock until EOF or drop.
///
/// **Note**: Cached via `OnceLock` in production — env var changes require
/// process restart. In test builds the env var is read directly so that
/// `temp_env` overrides take effect.
pub fn is_lock_optimization_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE,
rustfs_config::DEFAULT_OBJECT_LOCK_OPTIMIZATION_ENABLE,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_LOCK_OPTIMIZATION_ENABLE,
rustfs_config::DEFAULT_OBJECT_LOCK_OPTIMIZATION_ENABLE,
)
})
}
}
/// Check if deadlock detection is enabled.
/// When enabled, lock operations are recorded for deadlock analysis.
///
/// **Note**: Cached via `OnceLock` — env var changes require process restart.
pub fn is_deadlock_detection_enabled() -> bool {
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
rustfs_config::ENV_OBJECT_DEADLOCK_DETECTION_ENABLE,
rustfs_config::DEFAULT_OBJECT_DEADLOCK_DETECTION_ENABLE,
)
})
}
// ============================================================================
// GET Optimization Flag Functions
//
// All functions use `OnceLock` for caching. Environment variable changes
// require process restart to take effect.
// ============================================================================
/// Check if codec streaming is enabled (base flag).
///
/// **Note**: Cached via `OnceLock` — env var changes require process restart.
/// In test mode, bypasses cache to allow per-test env var overrides.
fn is_get_codec_streaming_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENABLE)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE, DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENABLE)
})
}
}
/// Check if multipart codec streaming is enabled.
///
/// When enabled, multipart objects use per-part codec streaming
/// instead of falling back to the legacy duplex path.
fn is_codec_streaming_multipart_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MULTIPART_ENABLE,
)
})
}
}
fn get_codec_streaming_multipart_max_parts() -> usize {
#[cfg(test)]
{
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<usize> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_MULTIPART_MAX_PARTS,
)
})
}
}
/// Check if metadata early-stop is enabled (base flag).
///
/// **Note**: Cached via `OnceLock` in production. In test builds the env var
/// is read directly so that `temp_env` overrides take effect.
fn is_get_metadata_early_stop_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE, DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE)
})
}
}
/// Check if version-aware early-stop is enabled.
///
/// When enabled, versioned requests can early-stop when the requested
/// version_id reaches quorum across disks.
///
/// **Note**: Cached via `OnceLock` in production. In test builds the env var
/// is read directly so that `temp_env` overrides take effect.
fn is_version_early_stop_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE,
DEFAULT_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE,
DEFAULT_RUSTFS_GET_METADATA_VERSION_EARLY_STOP_ENABLE,
)
})
}
}
// --- Rollout Percentage Functions ---
fn get_codec_streaming_rollout_pct() -> u32 {
#[cfg(test)]
{
rustfs_utils::get_env_u32(ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT, DEFAULT_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<u32> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_u32(ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT, DEFAULT_RUSTFS_GET_CODEC_STREAMING_ROLLOUT_PCT)
})
}
}
fn get_metadata_early_stop_rollout_pct() -> u32 {
static CACHED: OnceLock<u32> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_u32(
ENV_RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT,
DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ROLLOUT_PCT,
)
})
}
// --- Request-Level Decision Functions ---
/// Determine if an optimization should be enabled for a specific request.
///
/// Uses a stable hash of `(bucket, object)` to ensure the same object
/// always gets consistent behavior. This enables percentage-based gradual rollout.
fn is_optimization_enabled_for_request(base_enabled: bool, rollout_pct: u32, bucket: &str, object: &str) -> bool {
if !base_enabled || rollout_pct == 0 {
return false;
}
if rollout_pct >= 100 {
return true;
}
// Stable hash: same (bucket, object) always produces the same result
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bucket.hash(&mut hasher);
object.hash(&mut hasher);
let hash = hasher.finish() % 100;
(hash as u32) < rollout_pct
}
/// Should this specific request use codec streaming?
pub fn should_use_codec_streaming(bucket: &str, object: &str) -> bool {
let base = is_get_codec_streaming_enabled();
let pct = get_codec_streaming_rollout_pct();
is_optimization_enabled_for_request(base, pct, bucket, object)
}
/// Should this specific request use metadata early-stop?
pub fn should_use_metadata_early_stop(bucket: &str, object: &str) -> bool {
let base = is_get_metadata_early_stop_enabled();
let pct = get_metadata_early_stop_rollout_pct();
is_optimization_enabled_for_request(base, pct, bucket, object)
}
fn get_codec_streaming_min_size() -> usize {
if std::env::var_os(ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE).is_some() {
return rustfs_utils::get_env_usize(ENV_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE, DEFAULT_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE);
}
match get_codec_streaming_engine() {
GetCodecStreamingEngine::Rustfs => rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_RUSTFS_MIN_SIZE,
),
GetCodecStreamingEngine::Legacy => DEFAULT_RUSTFS_GET_CODEC_STREAMING_MIN_SIZE,
}
}
fn is_get_codec_streaming_data_blocks_first_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(
ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_ENABLE,
)
})
}
}
fn get_codec_streaming_data_blocks_first_max_size() -> usize {
#[cfg(test)]
{
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<usize> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE,
DEFAULT_RUSTFS_GET_CODEC_STREAMING_DATA_BLOCKS_FIRST_MAX_SIZE,
)
})
}
}
fn get_object_metadata_cache_max_entries() -> usize {
#[cfg(test)]
{
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES,
DEFAULT_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES,
)
.max(1)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<usize> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES,
DEFAULT_GET_OBJECT_METADATA_CACHE_MAX_ENTRIES,
)
.max(1)
})
}
}
fn is_get_small_object_direct_memory_enabled() -> bool {
#[cfg(test)]
{
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY, DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY, DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY)
})
}
}
fn get_small_object_direct_memory_threshold() -> usize {
#[cfg(test)]
{
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD,
DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD,
)
}
#[cfg(not(test))]
{
static CACHED: OnceLock<usize> = OnceLock::new();
*CACHED.get_or_init(|| {
rustfs_utils::get_env_usize(
ENV_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD,
DEFAULT_RUSTFS_GET_SMALL_OBJECT_DIRECT_MEMORY_THRESHOLD,
)
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GetCodecStreamingEngine {
Legacy,
Rustfs,
}
fn get_codec_streaming_engine() -> GetCodecStreamingEngine {
let engine = rustfs_utils::get_env_str(ENV_RUSTFS_GET_CODEC_STREAMING_ENGINE, DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENGINE);
match engine.trim() {
value if value.eq_ignore_ascii_case(GET_CODEC_STREAMING_ENGINE_RUSTFS) => GetCodecStreamingEngine::Rustfs,
value if value.eq_ignore_ascii_case(GET_CODEC_STREAMING_ENGINE_LEGACY) => GetCodecStreamingEngine::Legacy,
_ => GetCodecStreamingEngine::Legacy,
}
}
fn get_codec_streaming_rollout() -> GetCodecStreamingRollout {
let rollout = rustfs_utils::get_env_str(ENV_RUSTFS_GET_CODEC_STREAMING_ROLLOUT, DEFAULT_RUSTFS_GET_CODEC_STREAMING_ROLLOUT);
match rollout.trim() {
value if value.eq_ignore_ascii_case("internal") => GetCodecStreamingRollout::Internal,
value if value.eq_ignore_ascii_case("benchmark") => GetCodecStreamingRollout::Benchmark,
_ => GetCodecStreamingRollout::Off,
}
}
fn is_get_codec_streaming_body_compat_confirmed() -> bool {
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, false)
}
fn is_get_codec_streaming_header_compat_confirmed() -> bool {
rustfs_utils::get_env_bool(ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, false)
}
fn build_get_codec_streaming_decode_engine(erasure: coding::Erasure) -> std::io::Result<CodecStreamingDecodeEngine> {
match get_codec_streaming_engine() {
GetCodecStreamingEngine::Legacy => Ok(CodecStreamingDecodeEngine::legacy(erasure)),
GetCodecStreamingEngine::Rustfs => CodecStreamingDecodeEngine::rustfs(&erasure),
}
}
fn get_codec_streaming_metrics_path() -> &'static str {
match get_codec_streaming_engine() {
GetCodecStreamingEngine::Legacy => GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE,
GetCodecStreamingEngine::Rustfs => GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GetCodecStreamingDecision {
Use,
Fallback(GetCodecStreamingFallbackReason),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GetCodecStreamingRollout {
Off,
Internal,
Benchmark,
}
impl GetCodecStreamingRollout {
const fn is_opted_in(self) -> bool {
!matches!(self, Self::Off)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GetCodecStreamingFallbackReason {
Disabled,
RolloutNotOptedIn,
RolloutPctNotSelected,
BodyCompatibilityUnconfirmed,
HeaderCompatibilityUnconfirmed,
LockOptimizationDisabled,
Range,
BelowMinSize,
Encrypted,
Compressed,
Remote,
Multipart,
InvalidMinSize,
ReadQuorumNotSafe,
MultipartPartLimit,
}
impl GetCodecStreamingFallbackReason {
const fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::RolloutNotOptedIn => "rollout_not_opted_in",
Self::RolloutPctNotSelected => "rollout_pct_not_selected",
Self::BodyCompatibilityUnconfirmed => "body_compatibility_unconfirmed",
Self::HeaderCompatibilityUnconfirmed => "header_compatibility_unconfirmed",
Self::LockOptimizationDisabled => "lock_optimization_disabled",
Self::Range => "range",
Self::BelowMinSize => "below_min_size",
Self::Encrypted => "encrypted",
Self::Compressed => "compressed",
Self::Remote => "remote",
Self::Multipart => "multipart",
Self::InvalidMinSize => "invalid_min_size",
Self::ReadQuorumNotSafe => "read_quorum_not_safe",
Self::MultipartPartLimit => "multipart_part_limit",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GetCodecStreamingObjectClass {
PlainSinglePart,
Range,
Encrypted,
Compressed,
Remote,
Multipart,
}
impl GetCodecStreamingObjectClass {
const fn as_str(self) -> &'static str {
match self {
Self::PlainSinglePart => crate::diagnostics::get::GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
Self::Range => crate::diagnostics::get::GET_CODEC_STREAMING_OBJECT_CLASS_RANGE,
Self::Encrypted => crate::diagnostics::get::GET_CODEC_STREAMING_OBJECT_CLASS_ENCRYPTED,
Self::Compressed => crate::diagnostics::get::GET_CODEC_STREAMING_OBJECT_CLASS_COMPRESSED,
Self::Remote => crate::diagnostics::get::GET_CODEC_STREAMING_OBJECT_CLASS_REMOTE,
Self::Multipart => crate::diagnostics::get::GET_CODEC_STREAMING_OBJECT_CLASS_MULTIPART,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct GetCodecStreamingGate {
object_class: GetCodecStreamingObjectClass,
decision: GetCodecStreamingDecision,
prefer_data_blocks_first_reader_setup: bool,
}
fn record_get_codec_streaming_gate_decision(
object_class: GetCodecStreamingObjectClass,
decision: GetCodecStreamingDecision,
size_bucket: &'static str,
) {
let (outcome, reason) = match decision {
GetCodecStreamingDecision::Use => (
crate::diagnostics::get::GET_CODEC_STREAMING_DECISION_USE,
crate::diagnostics::get::GET_CODEC_STREAMING_REASON_NONE,
),
GetCodecStreamingDecision::Fallback(reason) => {
(crate::diagnostics::get::GET_CODEC_STREAMING_DECISION_FALLBACK, reason.as_str())
}
};
let object_class = object_class.as_str();
rustfs_io_metrics::record_get_object_codec_streaming_decision(outcome, object_class, reason);
rustfs_io_metrics::record_get_object_codec_streaming_decision_by_size(outcome, object_class, reason, size_bucket);
}
fn record_get_object_reader_path_observation(
path: &'static str,
object_class: GetCodecStreamingObjectClass,
size_bucket: &'static str,
) {
rustfs_io_metrics::record_get_object_reader_path(path);
rustfs_io_metrics::record_get_object_reader_path_by_size(path, object_class.as_str(), size_bucket);
}
fn classify_get_codec_streaming_object_class(
range: &Option<HTTPRangeSpec>,
object_info: &ObjectInfo,
fi: &FileInfo,
) -> GetCodecStreamingObjectClass {
if range.is_some() {
return GetCodecStreamingObjectClass::Range;
}
if object_info.is_encrypted() {
return GetCodecStreamingObjectClass::Encrypted;
}
if object_info.is_compressed() {
return GetCodecStreamingObjectClass::Compressed;
}
if object_info.is_remote() {
return GetCodecStreamingObjectClass::Remote;
}
if fi.parts.len() != 1 {
return GetCodecStreamingObjectClass::Multipart;
}
GetCodecStreamingObjectClass::PlainSinglePart
}
fn is_get_small_object_direct_memory_eligible_with_threshold(
range: &Option<HTTPRangeSpec>,
object_info: &ObjectInfo,
fi: &FileInfo,
opts: &ObjectOptions,
threshold: usize,
) -> bool {
if threshold == 0
|| range.is_some()
|| opts.part_number.is_some()
|| opts.version_id.is_some()
|| opts.versioned
|| opts.version_suspended
|| opts.incl_free_versions
|| opts.skip_free_version
|| opts.data_movement
|| opts.raw_data_movement_read
|| object_info.delete_marker
|| object_info.metadata_only
|| object_info.version_only
|| object_info.is_encrypted()
|| object_info.is_compressed()
|| object_info.is_remote()
|| object_info.parts.len() != 1
|| fi.parts.len() != 1
|| fi.size <= 0
{
return false;
}
let Ok(object_size) = usize::try_from(fi.size) else {
return false;
};
object_size <= threshold
}
fn is_get_small_object_direct_memory_eligible(
range: &Option<HTTPRangeSpec>,
object_info: &ObjectInfo,
fi: &FileInfo,
opts: &ObjectOptions,
) -> bool {
is_get_small_object_direct_memory_enabled()
&& is_get_small_object_direct_memory_eligible_with_threshold(
range,
object_info,
fi,
opts,
get_small_object_direct_memory_threshold(),
)
}
fn should_prefer_codec_streaming_data_blocks_first_reader_setup(
object_class: GetCodecStreamingObjectClass,
object_size: i64,
) -> bool {
if !is_get_codec_streaming_data_blocks_first_enabled()
|| object_class != GetCodecStreamingObjectClass::PlainSinglePart
|| object_size <= 0
{
return false;
}
let Ok(object_size) = usize::try_from(object_size) else {
return false;
};
let max_size = get_codec_streaming_data_blocks_first_max_size();
max_size > 0 && object_size <= max_size
}
fn get_codec_streaming_reader_gate(
bucket: &str,
object: &str,
range: &Option<HTTPRangeSpec>,
object_info: &ObjectInfo,
fi: &FileInfo,
lock_optimization_enabled: bool,
) -> GetCodecStreamingGate {
let object_class = classify_get_codec_streaming_object_class(range, object_info, fi);
if !is_get_codec_streaming_enabled() {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Disabled),
prefer_data_blocks_first_reader_setup: false,
};
}
if !get_codec_streaming_rollout().is_opted_in() {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::RolloutNotOptedIn),
prefer_data_blocks_first_reader_setup: false,
};
}
if !should_use_codec_streaming(bucket, object) {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::RolloutPctNotSelected),
prefer_data_blocks_first_reader_setup: false,
};
}
if !is_get_codec_streaming_body_compat_confirmed() {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::BodyCompatibilityUnconfirmed),
prefer_data_blocks_first_reader_setup: false,
};
}
if !is_get_codec_streaming_header_compat_confirmed() {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::HeaderCompatibilityUnconfirmed),
prefer_data_blocks_first_reader_setup: false,
};
}
if object_class == GetCodecStreamingObjectClass::Range {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Range),
prefer_data_blocks_first_reader_setup: false,
};
}
if !lock_optimization_enabled {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::LockOptimizationDisabled),
prefer_data_blocks_first_reader_setup: false,
};
}
let Ok(min_size) = i64::try_from(get_codec_streaming_min_size()) else {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::InvalidMinSize),
prefer_data_blocks_first_reader_setup: false,
};
};
if object_info.size < min_size {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::BelowMinSize),
prefer_data_blocks_first_reader_setup: false,
};
}
if object_class == GetCodecStreamingObjectClass::Encrypted {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Encrypted),
prefer_data_blocks_first_reader_setup: false,
};
}
if object_class == GetCodecStreamingObjectClass::Compressed {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Compressed),
prefer_data_blocks_first_reader_setup: false,
};
}
if object_class == GetCodecStreamingObjectClass::Remote {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Remote),
prefer_data_blocks_first_reader_setup: false,
};
}
if object_class == GetCodecStreamingObjectClass::Multipart {
if !is_codec_streaming_multipart_enabled() {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::Multipart),
prefer_data_blocks_first_reader_setup: false,
};
}
if fi.parts.len() > get_codec_streaming_multipart_max_parts() {
return GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::MultipartPartLimit),
prefer_data_blocks_first_reader_setup: false,
};
}
}
GetCodecStreamingGate {
object_class,
decision: GetCodecStreamingDecision::Use,
prefer_data_blocks_first_reader_setup: should_prefer_codec_streaming_data_blocks_first_reader_setup(
object_class,
object_info.size,
),
}
}
fn is_confirmed_complete_part_missing(err: &str) -> bool {
err.contains("file not found")
|| err.contains("Specified part could not be found")
|| (err.starts_with("part.") && err.ends_with(" not found"))
}
fn complete_multipart_part_error(part_number: usize, err: &str, bucket: &str, object: &str) -> Error {
if is_confirmed_complete_part_missing(err) {
return Error::InvalidPart(part_number, bucket.to_owned(), object.to_owned());
}
to_object_err(Error::ErasureReadQuorum, vec![bucket, object])
}
fn complete_multipart_part_error_result(err: &Error) -> &'static str {
match err {
Error::InvalidPart(_, _, _) => COMPLETE_MULTIPART_PART_MISSING,
Error::ErasureReadQuorum | Error::InsufficientReadQuorum(_, _) => COMPLETE_MULTIPART_PART_READ_QUORUM_UNAVAILABLE,
_ => COMPLETE_MULTIPART_PART_ERROR,
}
}
/// Record a lock acquisition for deadlock detection.
/// This records detailed lock information for deadlock analysis.
/// Returns the lock_id for later release tracking.
#[inline]
fn record_lock_acquire(bucket: &str, object: &str, lock_type: &str) -> String {
let lock_id = format!("{}:{}", bucket, object);
if !is_deadlock_detection_enabled() {
return lock_id;
}
let request_id = format!("get-{}-{}", bucket, object);
let resource = format!("{}/{}", bucket, object);
// Log with structured fields for analysis
debug!(
request_id = %request_id,
lock_id = %lock_id,
lock_type = %lock_type,
resource = %resource,
"Lock acquired for deadlock tracking"
);
lock_id
}
/// Record a lock release for deadlock detection.
#[inline]
fn record_lock_release(bucket: &str, object: &str, lock_id: &str, lock_type: &str) {
if !is_deadlock_detection_enabled() {
return;
}
let request_id = format!("get-{}-{}", bucket, object);
debug!(
request_id = %request_id,
lock_id = %lock_id,
lock_type = %lock_type,
"Lock released for deadlock tracking"
);
}
#[derive(Clone, Copy, Debug)]
pub(super) struct MultipartWriteQuorumContext<'a> {
stage: &'static str,
bucket: &'a str,
object: &'a str,
upload_id: &'a str,
part_number: Option<usize>,
}
fn log_multipart_write_quorum_failure(
context: MultipartWriteQuorumContext<'_>,
errs: &[Option<DiskError>],
write_quorum: usize,
returned_error: &DiskError,
) {
let summary = build_write_quorum_failure_summary(errs, OBJECT_OP_IGNORED_ERRS, write_quorum);
runtime_sources::record_erasure_write_quorum_failure(context.stage, summary.dominant_error_label);
warn!(
target: "rustfs_ecstore::set_disk",
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
op = "upload_part",
state = "write_quorum_unavailable",
stage = context.stage,
bucket = %context.bucket,
object = %context.object,
upload_id = %context.upload_id,
part_number = context.part_number,
required = summary.required,
achieved = summary.achieved,
failed = summary.failed,
total = summary.total,
offline_disks = summary.offline_disks,
retryable_failures = summary.retryable_failures,
dominant_error = summary.dominant_error_label,
returned_error = %returned_error,
"Set disk multipart write quorum unavailable"
);
}
fn issue3031_diag_enabled() -> bool {
rustfs_utils::get_env_bool(ENV_ISSUE3031_DIAG_ENABLE, false)
}
fn build_tiered_decommission_file_info(
bucket: &str,
object: &str,
fi: &FileInfo,
disk_count: usize,
default_parity_count: usize,
storage_class: Option<&str>,
) -> (FileInfo, usize) {
let parity_drives = runtime_sources::storage_class_parity(storage_class).unwrap_or(default_parity_count);
let data_drives = disk_count - parity_drives;
let mut write_quorum = data_drives;
if data_drives == parity_drives {
write_quorum += 1;
}
let mut updated = fi.clone();
updated.erasure = FileInfo::new([bucket, object].join("/").as_str(), data_drives, parity_drives).erasure;
(updated, write_quorum)
}
fn resolve_tiered_decommission_write_quorum_result(
errs: &[Option<DiskError>],
write_quorum: usize,
bucket: &str,
object: &str,
) -> Result<()> {
if let Some(err) = reduce_write_quorum_errs(errs, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(to_object_err(err.into(), vec![bucket, object]));
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct SetDisks {
pub locker_owner: String,
pub disks: Arc<RwLock<Vec<Option<DiskStore>>>>,
pub set_endpoints: Vec<Endpoint>,
pub set_drive_count: usize,
pub default_parity_count: usize,
pub set_index: usize,
pub pool_index: usize,
pub format: FormatV3,
disk_health_cache: Arc<RwLock<Vec<Option<DiskHealthEntry>>>>,
get_object_metadata_cache: moka::future::Cache<GetObjectMetadataCacheKey, Arc<GetObjectMetadataCacheEntry>>,
pub lockers: Vec<Arc<dyn LockClient>>,
local_lock_manager: Arc<rustfs_lock::GlobalLockManager>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct GetObjectMetadataCacheKey {
bucket: String,
object: String,
}
impl GetObjectMetadataCacheKey {
fn new(bucket: &str, object: &str) -> Self {
Self {
bucket: bucket.to_string(),
object: object.to_string(),
}
}
}
#[derive(Clone, Debug)]
struct GetObjectMetadataCacheEntry {
#[allow(dead_code)] // Kept for debugging; moka handles TTL internally
created_at: Instant,
fi: FileInfo,
parts_metadata: Vec<FileInfo>,
online_disks: Vec<Option<DiskStore>>,
read_quorum: usize,
}
#[derive(Clone, Debug)]
struct DiskHealthEntry {
last_check: Instant,
online: bool,
}
impl DiskHealthEntry {
fn cached_value(&self) -> Option<bool> {
if self.last_check.elapsed() <= DISK_HEALTH_CACHE_TTL {
Some(self.online)
} else {
None
}
}
}
impl SetDisks {
async fn invalidate_get_object_metadata_cache(&self, bucket: &str, object: &str) {
self.get_object_metadata_cache
.invalidate(&GetObjectMetadataCacheKey::new(bucket, object))
.await;
}
async fn acquire_read_lock_diag(&self, op: &'static str, bucket: &str, object: &str) -> Result<ObjectLockDiagGuard> {
let diag_enabled = is_object_lock_diag_enabled();
let ns_lock = self.new_ns_lock(bucket, object).await?;
let acquire_start = Instant::now();
let guard = ns_lock
.get_read_lock(get_lock_acquire_timeout())
.await
.map_err(|e| self.map_namespace_lock_error(bucket, object, "read", e))?;
let owner = diag_enabled.then(|| ns_lock.owner().to_string());
self.log_object_lock_acquire_if_slow(op, bucket, object, "read", owner.as_deref(), acquire_start.elapsed(), diag_enabled);
Ok(ObjectLockDiagGuard::new(
guard,
diag_enabled,
op,
diag_enabled.then(|| bucket.to_string()),
diag_enabled.then(|| object.to_string()),
owner,
"read",
))
}
async fn acquire_write_lock_diag(&self, op: &'static str, bucket: &str, object: &str) -> Result<ObjectLockDiagGuard> {
let diag_enabled = is_object_lock_diag_enabled();
let ns_lock = self.new_ns_lock(bucket, object).await?;
let acquire_start = Instant::now();
let guard = ns_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(|e| self.map_namespace_lock_error(bucket, object, "write", e))?;
let owner = diag_enabled.then(|| ns_lock.owner().to_string());
self.log_object_lock_acquire_if_slow(
op,
bucket,
object,
"write",
owner.as_deref(),
acquire_start.elapsed(),
diag_enabled,
);
Ok(ObjectLockDiagGuard::new(
guard,
diag_enabled,
op,
diag_enabled.then(|| bucket.to_string()),
diag_enabled.then(|| object.to_string()),
owner,
"write",
))
}
#[allow(clippy::too_many_arguments)]
fn log_object_lock_acquire_if_slow(
&self,
op: &'static str,
bucket: &str,
object: &str,
mode: &'static str,
owner: Option<&str>,
elapsed: Duration,
diag_enabled: bool,
) {
if !diag_enabled {
return;
}
let threshold = get_object_lock_diag_slow_acquire_threshold();
record_object_lock_diag_acquire_duration(op, mode, elapsed);
if elapsed >= threshold {
record_object_lock_diag_slow_acquire(op, mode);
warn!(
target: "rustfs_ecstore::object_lock_diag",
op,
bucket,
object,
mode,
owner = owner.unwrap_or_default(),
acquire_ms = elapsed.as_millis(),
threshold_ms = threshold.as_millis(),
"object namespace lock acquisition exceeded threshold"
);
}
}
#[allow(clippy::too_many_arguments)]
pub async fn new(
locker_owner: String,
disks: Arc<RwLock<Vec<Option<DiskStore>>>>,
set_drive_count: usize,
default_parity_count: usize,
set_index: usize,
pool_index: usize,
set_endpoints: Vec<Endpoint>,
format: FormatV3,
lockers: Vec<Arc<dyn LockClient>>,
) -> Arc<Self> {
Arc::new(SetDisks {
locker_owner,
disks,
set_drive_count,
default_parity_count,
set_index,
pool_index,
format,
set_endpoints,
disk_health_cache: Arc::new(RwLock::new(Vec::new())),
get_object_metadata_cache: moka::future::Cache::builder()
.max_capacity(get_object_metadata_cache_max_entries() as u64)
.time_to_live(GET_OBJECT_METADATA_CACHE_TTL)
.build(),
lockers,
local_lock_manager: runtime_sources::global_lock_manager(),
})
}
// async fn cached_disk_health(&self, index: usize) -> Option<bool> {
// let cache = self.disk_health_cache.read().await;
// cache
// .get(index)
// .and_then(|entry| entry.as_ref().and_then(|state| state.cached_value()))
// }
// async fn update_disk_health(&self, index: usize, online: bool) {
// let mut cache = self.disk_health_cache.write().await;
// if cache.len() <= index {
// cache.resize(index + 1, None);
// }
// cache[index] = Some(DiskHealthEntry {
// last_check: Instant::now(),
// online,
// });
// }
// async fn is_disk_online_cached(&self, index: usize, disk: &DiskStore) -> bool {
// if let Some(online) = self.cached_disk_health(index).await {
// return online;
// }
// let disk_clone = disk.clone();
// let online = timeout(DISK_ONLINE_TIMEOUT, async move { disk_clone.is_online().await })
// .await
// .unwrap_or(false);
// self.update_disk_health(index, online).await;
// online
// }
// async fn filter_online_disks(&self, disks: Vec<Option<DiskStore>>) -> (Vec<Option<DiskStore>>, usize) {
// let mut filtered = Vec::with_capacity(disks.len());
// let mut online_count = 0;
// for (idx, disk) in disks.into_iter().enumerate() {
// if let Some(disk_store) = disk {
// if self.is_disk_online_cached(idx, &disk_store).await {
// filtered.push(Some(disk_store));
// online_count += 1;
// } else {
// filtered.push(None);
// }
// } else {
// filtered.push(None);
// }
// }
// (filtered, online_count)
// }
// async fn write_all(disks: &[Option<DiskStore>], bucket: &str, object: &str, buff: Vec<u8>) -> Vec<Option<Error>> {
// let mut futures = Vec::with_capacity(disks.len());
// let mut errors = Vec::with_capacity(disks.len());
// for disk in disks.iter() {
// if disk.is_none() {
// errors.push(Some(Error::new(DiskError::DiskNotFound)));
// continue;
// }
// let disk = disk.as_ref().unwrap();
// futures.push(disk.write_all(bucket, object, buff.clone()));
// }
// let results = join_all(futures).await;
// for result in results {
// match result {
// Ok(_) => {
// errors.push(None);
// }
// Err(e) => {
// errors.push(Some(e));
// }
// }
// }
// errors
// }
// Returns per object readQuorum and writeQuorum
// readQuorum is the min required disks to read data.
// writeQuorum is the min required disks to write data.
// Optimized version using batch processor with quorum support
// pub async fn walk_dir(&self, opts: &WalkDirOptions) -> (Vec<Option<Vec<MetaCacheEntry>>>, Vec<Option<Error>>) {
// let disks = self.disks.read().await;
// let disks = disks.clone();
// let mut futures = Vec::new();
// let mut errs = Vec::new();
// let mut ress = Vec::new();
// for disk in disks.iter() {
// let opts = opts.clone();
// futures.push(async move {
// if let Some(disk) = disk {
// disk.walk_dir(opts, &mut Writer::NotUse).await
// } else {
// Err(DiskError::DiskNotFound)
// }
// });
// }
// let results = join_all(futures).await;
// for res in results {
// match res {
// Ok(entries) => {
// ress.push(Some(entries));
// errs.push(None);
// }
// Err(e) => {
// ress.push(None);
// errs.push(Some(e));
// }
// }
// }
// (ress, errs)
// }
// async fn remove_object_part(
// &self,
// bucket: &str,
// object: &str,
// upload_id: &str,
// data_dir: &str,
// part_num: usize,
// ) -> Result<()> {
// let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
// let disks = self.disks.read().await;
// let disks = disks.clone();
// let file_path = format!("{}/{}/part.{}", upload_id_path, data_dir, part_num);
// let mut futures = Vec::with_capacity(disks.len());
// let mut errors = Vec::with_capacity(disks.len());
// for disk in disks.iter() {
// let file_path = file_path.clone();
// let meta_file_path = format!("{}.meta", file_path);
// futures.push(async move {
// if let Some(disk) = disk {
// disk.delete(RUSTFS_META_MULTIPART_BUCKET, &file_path, DeleteOptions::default())
// .await?;
// disk.delete(RUSTFS_META_MULTIPART_BUCKET, &meta_file_path, DeleteOptions::default())
// .await
// } else {
// Err(DiskError::DiskNotFound)
// }
// });
// }
// let results = join_all(futures).await;
// for result in results {
// match result {
// Ok(_) => {
// errors.push(None);
// }
// Err(e) => {
// errors.push(Some(e));
// }
// }
// }
// Ok(())
// }
// async fn remove_part_meta(&self, bucket: &str, object: &str, upload_id: &str, data_dir: &str, part_num: usize) -> Result<()> {
// let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
// let disks = self.disks.read().await;
// let disks = disks.clone();
// // let disks = Self::shuffle_disks(&disks, &fi.erasure.distribution);
// let file_path = format!("{}/{}/part.{}.meta", upload_id_path, data_dir, part_num);
// let mut futures = Vec::with_capacity(disks.len());
// let mut errors = Vec::with_capacity(disks.len());
// for disk in disks.iter() {
// let file_path = file_path.clone();
// futures.push(async move {
// if let Some(disk) = disk {
// disk.delete(RUSTFS_META_MULTIPART_BUCKET, &file_path, DeleteOptions::default())
// .await
// } else {
// Err(DiskError::DiskNotFound)
// }
// });
// }
// let results = join_all(futures).await;
// for result in results {
// match result {
// Ok(_) => {
// errors.push(None);
// }
// Err(e) => {
// errors.push(Some(e));
// }
// }
// }
// Ok(())
// }
// Shuffle the order
// Shuffle the order
// Return shuffled partsMetadata depending on distribution.
// shuffle_disks TODO: use origin value
}
fn is_explicit_null_version(version_id: Option<Uuid>) -> bool {
version_id == Some(Uuid::nil())
}
fn delete_file_info_version_id(version_id: Option<Uuid>) -> Option<Uuid> {
if is_explicit_null_version(version_id) {
None
} else {
version_id
}
}
fn object_fits_single_block(object_size: i64, block_size: usize) -> bool {
match usize::try_from(object_size) {
Ok(size) => size > 0 && size <= block_size,
Err(_) => false,
}
}
fn should_use_inline_small_fast_path(is_inline_buffer: bool, object_size: i64, block_size: usize) -> bool {
is_inline_buffer && object_fits_single_block(object_size, block_size)
}
fn should_use_single_block_non_inline_fast_path(is_inline_buffer: bool, object_size: i64, block_size: usize) -> bool {
!is_inline_buffer && object_fits_single_block(object_size, block_size)
}
enum SmallWritePath {
Inline,
SingleBlockNonInline,
Pipeline,
PipelineBatchedLarge,
}
impl SmallWritePath {
fn metric_label(&self) -> &'static str {
match self {
SmallWritePath::Inline => "write_inline",
SmallWritePath::SingleBlockNonInline => "write_single_block_non_inline",
SmallWritePath::Pipeline => "write_pipeline",
SmallWritePath::PipelineBatchedLarge => "write_pipeline_batched_large",
}
}
fn multipart_metric_label(&self) -> &'static str {
match self {
SmallWritePath::Inline => "multipart_write_inline",
SmallWritePath::SingleBlockNonInline => "multipart_write_single_block_non_inline",
SmallWritePath::Pipeline => "multipart_write_pipeline",
SmallWritePath::PipelineBatchedLarge => "multipart_write_pipeline_batched_large",
}
}
}
fn put_large_batch_min_size_bytes() -> usize {
*CACHED_PUT_LARGE_BATCH_MIN_SIZE_BYTES.get_or_init(|| {
rustfs_utils::get_env_usize(ENV_RUSTFS_PUT_LARGE_BATCH_MIN_SIZE_BYTES, DEFAULT_RUSTFS_PUT_LARGE_BATCH_MIN_SIZE_BYTES)
})
}
fn multipart_put_large_batch_min_size_bytes() -> usize {
*CACHED_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES.get_or_init(|| {
rustfs_utils::get_env_usize(
ENV_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES,
DEFAULT_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES,
)
})
}
fn classify_small_write_path(is_inline_buffer: bool, object_size: i64, block_size: usize) -> SmallWritePath {
if should_use_inline_small_fast_path(is_inline_buffer, object_size, block_size) {
SmallWritePath::Inline
} else if should_use_single_block_non_inline_fast_path(is_inline_buffer, object_size, block_size) {
SmallWritePath::SingleBlockNonInline
} else {
SmallWritePath::Pipeline
}
}
fn classify_put_write_path(is_inline_buffer: bool, object_size: i64, block_size: usize) -> SmallWritePath {
if should_use_inline_small_fast_path(is_inline_buffer, object_size, block_size) {
return SmallWritePath::Inline;
}
if should_use_single_block_non_inline_fast_path(is_inline_buffer, object_size, block_size) {
return SmallWritePath::SingleBlockNonInline;
}
match usize::try_from(object_size) {
Ok(size) if !is_inline_buffer && size >= put_large_batch_min_size_bytes() => SmallWritePath::PipelineBatchedLarge,
_ => SmallWritePath::Pipeline,
}
}
fn classify_multipart_part_write_path(object_size: i64, block_size: usize) -> SmallWritePath {
if should_use_single_block_non_inline_fast_path(false, object_size, block_size) {
return SmallWritePath::SingleBlockNonInline;
}
match usize::try_from(object_size) {
Ok(size) if size >= multipart_put_large_batch_min_size_bytes() => SmallWritePath::PipelineBatchedLarge,
_ => SmallWritePath::Pipeline,
}
}
fn known_put_object_storage_size(data_size: i64) -> i64 {
if data_size >= 0 {
data_size
} else {
HashReader::SIZE_PRESERVE_LAYER
}
}
#[allow(clippy::too_many_arguments)]
async fn build_inline_bitrot_readers(
files: &[FileInfo],
total_shards: usize,
bucket: &str,
object: &str,
read_length: usize,
shard_size: usize,
checksum_algo: &HashAlgorithm,
skip_verify_bitrot: bool,
) -> disk::error::Result<Vec<Option<InlineBitrotReader>>> {
let mut readers = Vec::with_capacity(total_shards);
for file in files.iter().take(total_shards) {
let reader = if let Some(data) = &file.data {
create_bitrot_reader_from_bytes(
Some(data.clone()),
None,
bucket,
object,
0,
read_length,
shard_size,
checksum_algo.clone(),
skip_verify_bitrot,
false,
)
.await?
} else {
None
};
readers.push(reader);
}
Ok(readers)
}
#[allow(clippy::too_many_arguments)]
async fn build_inline_bitrot_readers_from_refs(
files: &[&FileInfo],
bucket: &str,
object: &str,
read_length: usize,
shard_size: usize,
checksum_algo: &HashAlgorithm,
skip_verify_bitrot: bool,
) -> disk::error::Result<Vec<Option<InlineBitrotReader>>> {
let mut readers = Vec::with_capacity(files.len());
for file in files {
let reader = if let Some(data) = &file.data {
create_bitrot_reader_from_bytes(
Some(data.clone()),
None,
bucket,
object,
0,
read_length,
shard_size,
checksum_algo.clone(),
skip_verify_bitrot,
false,
)
.await?
} else {
None
};
readers.push(reader);
}
Ok(readers)
}
async fn try_read_inline_data_shards_direct(
readers: &mut [Option<InlineBitrotReader>],
data_shards: usize,
read_length: usize,
object_size: usize,
) -> Option<Bytes> {
if object_size == 0 || read_length == 0 || readers.len() < data_shards {
return None;
}
let mut body = Vec::with_capacity(object_size);
let mut remaining = object_size;
for reader in readers.iter_mut().take(data_shards) {
let reader = reader.as_mut()?;
let mut shard = vec![0u8; read_length];
let Ok(read) = reader.read(&mut shard).await else {
return None;
};
if read != read_length {
return None;
}
let take = remaining.min(shard.len());
body.extend_from_slice(&shard[..take]);
remaining -= take;
if remaining == 0 {
return Some(Bytes::from(body));
}
}
None
}
fn can_try_inline_data_shards_direct(object_size: usize, block_size: usize) -> bool {
object_size > 0 && object_size <= block_size
}
fn inline_erasure_shard_size(block_size: usize, data_shards: usize, uses_legacy: bool) -> usize {
if block_size == 0 || data_shards == 0 {
return 0;
}
if uses_legacy {
coding::calc_shard_size_legacy(block_size, data_shards)
} else {
coding::calc_shard_size(block_size, data_shards)
}
}
fn inline_erasure_shard_file_size(total_length: usize, block_size: usize, data_shards: usize, uses_legacy: bool) -> usize {
if total_length == 0 || block_size == 0 || data_shards == 0 {
return 0;
}
let shard_size = inline_erasure_shard_size(block_size, data_shards, uses_legacy);
let shard_size_fn = if uses_legacy {
coding::calc_shard_size_legacy
} else {
coding::calc_shard_size
};
let num_shards = total_length / block_size;
let last_block_size = total_length % block_size;
let last_shard_size = shard_size_fn(last_block_size, data_shards);
num_shards * shard_size + last_shard_size
}
fn inline_erasure_shard_file_offset(
start_offset: usize,
length: usize,
total_length: usize,
block_size: usize,
data_shards: usize,
uses_legacy: bool,
) -> usize {
if block_size == 0 || data_shards == 0 {
return 0;
}
let shard_size = inline_erasure_shard_size(block_size, data_shards, uses_legacy);
let shard_file_size = inline_erasure_shard_file_size(total_length, block_size, data_shards, uses_legacy);
let end_shard = (start_offset + length) / block_size;
let till_offset = end_shard * shard_size + shard_size;
till_offset.min(shard_file_size)
}
fn collect_inline_data_shard_fileinfos_by_index<'a>(
parts_metadata: &'a [FileInfo],
fi: &FileInfo,
data_shards: usize,
mut disk_is_online: impl FnMut(usize) -> bool,
) -> Option<Vec<&'a FileInfo>> {
let distribution = &fi.erasure.distribution;
let mut data_files = vec![None; data_shards];
for (disk_index, file_info) in parts_metadata.iter().enumerate() {
if !disk_is_online(disk_index) {
continue;
}
let block_index = *distribution.get(disk_index)?;
if block_index == 0 || block_index > data_shards {
continue;
}
if !file_info.is_valid() {
continue;
}
if file_info.data.as_ref().is_none_or(|data| data.is_empty()) {
continue;
}
data_files[block_index - 1] = Some(file_info);
}
data_files.into_iter().collect()
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
#[tracing::instrument(level = "debug", skip(self))]
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
h: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
let stage_metrics_enabled = rustfs_io_metrics::get_stage_metrics_enabled();
// Check if lock optimization is enabled for reads that are fully materialized in memory.
let lock_optimization_enabled = is_lock_optimization_enabled();
// Acquire a shared read-lock early to protect read consistency
let mut read_lock_guard = if !opts.no_lock {
let acquire_start = Instant::now();
let lock_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
// Record lock wait for deadlock detection
if is_deadlock_detection_enabled() {
debug!(
lock_id = format!("{}:{}", bucket, object),
lock_type = "read",
resource = format!("{}/{}", bucket, object),
"Waiting for read lock"
);
}
let guard = self.acquire_read_lock_diag("get_object", bucket, object).await?;
// Record lock acquisition for deadlock detection
let _lock_id = record_lock_acquire(bucket, object, "read");
// Record lock statistics
metrics::counter!("rustfs.lock.acquire.total", "type" => "read").increment(1);
metrics::histogram!("rustfs.lock.acquire.duration.seconds").record(acquire_start.elapsed().as_secs_f64());
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_SET_DISK, GET_STAGE_LOCK_ACQUIRE, lock_stage_start);
Some(guard)
} else {
None
};
let metadata_stage_start = Instant::now();
let (fi, files, disks) = match self.get_object_fileinfo(bucket, object, opts, true).await {
Ok(result) => result,
Err(err) => {
rustfs_io_metrics::record_get_object_metadata_phase_duration(metadata_stage_start.elapsed().as_secs_f64());
record_get_object_pipeline_failure(GET_STAGE_METADATA, classify_storage_error(&err));
return Err(to_object_err(err, vec![bucket, object]));
}
};
let object_info_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let object_info = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
let object_class = classify_get_codec_streaming_object_class(&range, &object_info, &fi);
let size_bucket = rustfs_io_metrics::get_object_size_bucket(object_info.size);
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_SET_DISK, GET_STAGE_OBJECT_INFO, object_info_stage_start);
let metadata_elapsed = metadata_stage_start.elapsed().as_secs_f64();
rustfs_io_metrics::record_get_object_metadata_phase_duration(metadata_elapsed);
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_SET_DISK,
GET_STAGE_METADATA,
object_class.as_str(),
size_bucket,
metadata_elapsed,
);
if object_info.delete_marker {
if opts.version_id.is_none() {
return Err(to_object_err(Error::FileNotFound, vec![bucket, object]));
}
return Err(to_object_err(Error::MethodNotAllowed, vec![bucket, object]));
}
// if object_info.size == 0 {
// let empty_rd: Box<dyn AsyncRead> = Box::new(Bytes::new());
// return Ok(GetObjectReader {
// stream: empty_rd,
// object_info,
// });
// }
if object_info.size == 0 {
record_get_object_reader_path_observation(GET_OBJECT_PATH_EMPTY, object_class, size_bucket);
// if let Some(rs) = range {
// let _ = rs.get_offset_length(object_info.size)?;
// }
let reader = GetObjectReader {
stream: Box::new(Cursor::new(Vec::new())),
object_info,
buffered_body: Some(Bytes::new()),
};
return Ok(reader);
}
// Inline data fast path: skip duplex pipe for small inline objects.
// Uses the shared predicate from ObjectInfo; additionally checks that
// inline data is actually present and no range request is in flight.
if object_info.is_inline_fast_path_eligible() && fi.data.is_some() && range.is_none() {
let mut inline_prepare_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let data_shards = fi.erasure.data_blocks;
let object_size = usize::try_from(fi.size)
.map_err(|_| to_object_err(Error::other("inline fast path object size is invalid"), vec![bucket, object]))?;
let checksum_info = fi.erasure.get_checksum_info(fi.parts[0].number);
let checksum_algo =
if fi.uses_legacy_checksum && checksum_info.algorithm == rustfs_utils::HashAlgorithm::HighwayHash256S {
rustfs_utils::HashAlgorithm::HighwayHash256SLegacy
} else {
checksum_info.algorithm
};
if can_try_inline_data_shards_direct(object_size, fi.erasure.block_size)
&& let Some(data_files) = collect_inline_data_shard_fileinfos_by_index(&files, &fi, data_shards, |index| {
disks.get(index).is_some_and(Option::is_some)
})
{
let read_length = inline_erasure_shard_file_offset(
0,
object_size,
object_size,
fi.erasure.block_size,
data_shards,
fi.uses_legacy_checksum,
);
let shard_size = inline_erasure_shard_size(fi.erasure.block_size, data_shards, fi.uses_legacy_checksum);
if let Some(inline_prepare_stage_start) = inline_prepare_stage_start.take() {
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_INLINE_DIRECT,
GET_STAGE_INLINE_PREPARE,
object_class.as_str(),
size_bucket,
inline_prepare_stage_start.elapsed().as_secs_f64(),
);
}
let reader_setup_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then(Instant::now);
let mut readers = build_inline_bitrot_readers_from_refs(
&data_files,
bucket,
object,
read_length,
shard_size,
&checksum_algo,
opts.skip_verify_bitrot,
)
.await?;
if let Some(reader_setup_stage_start) = reader_setup_stage_start {
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_INLINE_DIRECT,
GET_STAGE_READER_SETUP,
object_class.as_str(),
size_bucket,
reader_setup_stage_start.elapsed().as_secs_f64(),
);
}
// Decode directly
let decode_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then(Instant::now);
if let Some(body) = try_read_inline_data_shards_direct(&mut readers, data_shards, read_length, object_size).await
{
if let Some(decode_stage_start) = decode_stage_start {
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_INLINE_DIRECT,
GET_STAGE_DECODE,
object_class.as_str(),
size_bucket,
decode_stage_start.elapsed().as_secs_f64(),
);
}
record_get_object_reader_path_observation(GET_OBJECT_PATH_INLINE_DIRECT, object_class, size_bucket);
let reader = GetObjectReader {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
};
return Ok(reader);
}
}
let erasure = coding::Erasure::new_with_options(
fi.erasure.data_blocks,
fi.erasure.parity_blocks,
fi.erasure.block_size,
fi.uses_legacy_checksum,
);
let read_length = erasure.shard_file_offset(0, object_size, object_size);
let total_shards = data_shards + fi.erasure.parity_blocks;
let (_disks, files) = Self::shuffle_disks_and_parts_metadata_by_index(&disks, &files, &fi);
// Check if we have enough inline data shards
let inline_count = files
.iter()
.take(data_shards)
.filter(|f| f.data.as_ref().is_some_and(|d| !d.is_empty()))
.count();
if inline_count >= data_shards {
if let Some(inline_prepare_stage_start) = inline_prepare_stage_start.take() {
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_INLINE_DIRECT,
GET_STAGE_INLINE_PREPARE,
object_class.as_str(),
size_bucket,
inline_prepare_stage_start.elapsed().as_secs_f64(),
);
}
let reader_setup_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then(Instant::now);
let readers = build_inline_bitrot_readers(
&files,
total_shards,
bucket,
object,
read_length,
erasure.shard_size(),
&checksum_algo,
opts.skip_verify_bitrot,
)
.await?;
if let Some(reader_setup_stage_start) = reader_setup_stage_start {
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_INLINE_DIRECT,
GET_STAGE_READER_SETUP,
object_class.as_str(),
size_bucket,
reader_setup_stage_start.elapsed().as_secs_f64(),
);
}
let decode_stage_start = rustfs_io_metrics::get_stage_metrics_enabled().then(Instant::now);
let mut output = Cursor::new(Vec::with_capacity(object_size));
let (written, err) = erasure.decode(&mut output, readers, 0, object_size, object_size).await;
if let Some(e) = err {
return Err(to_object_err(e.into(), vec![bucket, object]));
}
if written == 0 && fi.size > 0 {
return Err(to_object_err(
Error::other("inline fast path: erasure decode returned 0 bytes"),
vec![bucket, object],
));
}
let body = Bytes::from(output.into_inner());
if let Some(decode_stage_start) = decode_stage_start {
rustfs_io_metrics::record_get_object_stage_duration_by_size(
GET_OBJECT_PATH_INLINE_DIRECT,
GET_STAGE_DECODE,
object_class.as_str(),
size_bucket,
decode_stage_start.elapsed().as_secs_f64(),
);
}
record_get_object_reader_path_observation(GET_OBJECT_PATH_INLINE_DIRECT, object_class, size_bucket);
let reader = GetObjectReader {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
};
return Ok(reader);
}
}
let path_decision_stage_start = get_stage_timer_if_enabled(stage_metrics_enabled);
let codec_streaming_gate =
get_codec_streaming_reader_gate(bucket, object, &range, &object_info, &fi, lock_optimization_enabled);
record_get_stage_duration_if_enabled(GET_OBJECT_PATH_SET_DISK, GET_STAGE_PATH_DECISION, path_decision_stage_start);
if object_info.is_remote() {
if let GetCodecStreamingDecision::Fallback(reason) = codec_streaming_gate.decision {
record_get_codec_streaming_gate_decision(
codec_streaming_gate.object_class,
codec_streaming_gate.decision,
size_bucket,
);
rustfs_io_metrics::record_get_object_codec_streaming_fallback(reason.as_str());
}
record_get_object_reader_path_observation(GET_OBJECT_PATH_REMOTE_TRANSITION, object_class, size_bucket);
let mut opts = opts.clone();
if object_info.parts.len() == 1 {
opts.part_number = Some(1);
}
let gr = get_transitioned_object_reader(bucket, object, &range, &h, &object_info, &opts).await?;
return Ok(finish_set_disk_read_lock(
gr,
read_lock_guard.take(),
lock_optimization_enabled,
bucket,
object,
));
}
// App-layer object data cache probe: metadata (etag/size) is resolved
// but no data shards have been read yet, so a hit skips the erasure
// read, bitrot verify and decode entirely. The hook validates object
// identity and rejects anything it cannot serve byte-identically.
if range.is_none()
&& opts.part_number.is_none()
&& let Some(hook) = get_object_body_cache_hook()
&& let Some(body) = hook.lookup(bucket, object, &object_info).await
{
record_get_object_reader_path_observation(GET_OBJECT_PATH_BODY_CACHE, object_class, size_bucket);
let reader = GetObjectReader {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
};
if lock_optimization_enabled {
release_materialized_read_lock(bucket, object, read_lock_guard.take());
}
return Ok(reader);
}
if is_get_small_object_direct_memory_eligible(&range, &object_info, &fi, opts) {
let object_size = usize::try_from(object_info.size)
.map_err(|_| to_object_err(Error::other("direct-memory GET object size is invalid"), vec![bucket, object]))?;
if let Some(body) = Self::try_get_object_direct_data_shards_with_fileinfo(
bucket,
object,
&fi,
&files,
&disks,
opts.skip_verify_bitrot,
object_class.as_str(),
size_bucket,
)
.await?
{
if body.len() != object_size {
return Err(to_object_err(
Error::other("direct-memory GET decoded length mismatch"),
vec![bucket, object],
));
}
record_get_object_reader_path_observation(GET_OBJECT_PATH_DIRECT_MEMORY, object_class, size_bucket);
let reader = GetObjectReader {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
};
if lock_optimization_enabled {
release_materialized_read_lock(bucket, object, read_lock_guard.take());
debug!(bucket, object, "Lock optimization: released read lock after direct-memory read");
}
return Ok(reader);
}
let mut output = Vec::with_capacity(object_size);
Self::get_object_with_fileinfo(
bucket,
object,
0,
object_info.size,
&mut output,
fi,
files,
&disks,
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
true,
GET_OBJECT_PATH_DIRECT_MEMORY,
object_class.as_str(),
size_bucket,
)
.await?;
if output.len() != object_size {
return Err(to_object_err(
Error::other("direct-memory GET decoded length mismatch"),
vec![bucket, object],
));
}
record_get_object_reader_path_observation(GET_OBJECT_PATH_DIRECT_MEMORY, object_class, size_bucket);
let body = Bytes::from(output);
let reader = GetObjectReader {
stream: Box::new(Cursor::new(body.clone())),
object_info,
buffered_body: Some(body),
};
if lock_optimization_enabled {
release_materialized_read_lock(bucket, object, read_lock_guard.take());
debug!(bucket, object, "Lock optimization: released read lock after direct-memory read");
}
return Ok(reader);
}
match codec_streaming_gate.decision {
GetCodecStreamingDecision::Use => {
match Self::get_object_decode_reader_with_fileinfo(
bucket,
object,
&fi,
&files,
&disks,
self.set_index,
self.pool_index,
opts.skip_verify_bitrot,
object_class.as_str(),
size_bucket,
codec_streaming_gate.prefer_data_blocks_first_reader_setup,
)
.await?
{
read::GetCodecStreamingReaderBuildOutcome::Reader(stream) => {
record_get_codec_streaming_gate_decision(
codec_streaming_gate.object_class,
GetCodecStreamingDecision::Use,
size_bucket,
);
record_get_object_reader_path_observation(GET_OBJECT_PATH_CODEC_STREAMING, object_class, size_bucket);
let (reader, _offset, _length) = GetObjectReader::new(stream, range, &object_info, opts, &h).await?;
return Ok(finish_set_disk_read_lock(
reader,
read_lock_guard.take(),
lock_optimization_enabled,
bucket,
object,
));
}
read::GetCodecStreamingReaderBuildOutcome::Fallback(reason) => {
record_get_codec_streaming_gate_decision(
codec_streaming_gate.object_class,
GetCodecStreamingDecision::Fallback(reason),
size_bucket,
);
rustfs_io_metrics::record_get_object_codec_streaming_fallback(reason.as_str());
}
}
}
GetCodecStreamingDecision::Fallback(reason) => {
record_get_codec_streaming_gate_decision(
codec_streaming_gate.object_class,
codec_streaming_gate.decision,
size_bucket,
);
rustfs_io_metrics::record_get_object_codec_streaming_fallback(reason.as_str());
}
}
record_get_object_reader_path_observation(GET_OBJECT_PATH_LEGACY_DUPLEX, object_class, size_bucket);
let duplex_buffer_size = adaptive_duplex_buffer_size(object_info.size);
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
let (reader, offset, length) = GetObjectReader::new(Box::new(rd), range, &object_info, opts, &h).await?;
// let disks = disks.clone();
let bucket = bucket.to_owned();
let object = object.to_owned();
let set_index = self.set_index;
let pool_index = self.pool_index;
let skip_verify = opts.skip_verify_bitrot;
if lock_optimization_enabled {
release_materialized_read_lock(&bucket, &object, read_lock_guard.take());
debug!(bucket, object, "Lock optimization: released read lock before streaming read");
}
// When lock optimization is disabled, keep the read-lock guard in the
// task so it lives for the duration of the streaming read.
tokio::spawn(async move {
let _guard = read_lock_guard;
let mut writer = wd;
// Do not wrap the entire read+write pipeline in `disk_read_timeout`.
// `get_object_with_fileinfo` also waits on `writer`, so an outer timeout
// would incorrectly treat downstream backpressure as disk-read latency.
// Disk read timeouts must be enforced at the actual disk I/O operations.
if let Err(e) = Self::get_object_with_fileinfo(
&bucket,
&object,
offset,
length,
&mut writer,
fi,
files,
&disks,
set_index,
pool_index,
skip_verify,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
object_class.as_str(),
size_bucket,
)
.await
{
let reason = classify_storage_error(&e);
record_get_object_pipeline_failure(GET_STAGE_EMIT, reason);
error!(
event = EVENT_SET_DISK_WRITE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket,
object,
pool_index,
set_index,
offset,
requested_length = length,
skip_verify_bitrot = skip_verify,
state = "read_pipeline_failed",
stage = GET_STAGE_EMIT,
reason = reason.as_str(),
error = ?e,
"Set disk object read pipeline failed"
);
};
});
Ok(reader)
}
#[tracing::instrument(skip(self, data,))]
async fn put_object(&self, bucket: &str, object: &str, data: &mut PutObjReader, opts: &ObjectOptions) -> Result<ObjectInfo> {
self.invalidate_get_object_metadata_cache(bucket, object).await;
let disks = self.get_disks_internal().await;
let mut object_lock_guard = None;
if opts.http_preconditions.is_some() {
if !opts.no_lock {
object_lock_guard = Some(
self.acquire_write_lock_diag("put_object_precondition", bucket, object)
.await?,
);
}
if let Some(err) = self.check_write_precondition(bucket, object, opts).await {
return Err(err);
}
}
let mut user_defined = opts.user_defined.clone();
if let Some(eval_metadata) = &opts.eval_metadata {
for (key, value) in eval_metadata {
user_defined.insert(key.clone(), value.clone());
}
}
let sc_parity_drives = runtime_sources::storage_class_parity(user_defined.get(AMZ_STORAGE_CLASS).map(String::as_str));
let mut parity_drives = sc_parity_drives.unwrap_or(self.default_parity_count);
if opts.max_parity {
parity_drives = disks.len() / 2;
}
let data_drives = disks.len() - parity_drives;
let mut write_quorum = data_drives;
if data_drives == parity_drives {
write_quorum += 1
}
// if filtered_online < write_quorum {
// warn!(
// "online disk snapshot {} below write quorum {} for {}/{}; returning erasure write quorum error",
// filtered_online, write_quorum, bucket, object
// );
// return Err(to_object_err(Error::ErasureWriteQuorum, vec![bucket, object]));
// }
let mut fi = FileInfo::new([bucket, object].join("/").as_str(), data_drives, parity_drives);
fi.version_id = {
if let Some(ref vid) = opts.version_id {
Some(Uuid::parse_str(vid.as_str()).map_err(Error::other)?)
} else {
None
}
};
if opts.versioned && fi.version_id.is_none() {
fi.version_id = Some(Uuid::new_v4());
}
fi.data_dir = Some(Uuid::new_v4());
let parts_metadata = vec![fi.clone(); disks.len()];
let (shuffle_disks, mut parts_metadatas) = Self::shuffle_disks_and_parts_metadata(&disks, &parts_metadata, &fi);
let tmp_dir = Uuid::new_v4().to_string();
let tmp_object = format!("{}/{}/part.1", tmp_dir, fi.data_dir.unwrap());
let result: Result<ObjectInfo> = async {
let erasure = coding::Erasure::new(fi.erasure.data_blocks, fi.erasure.parity_blocks, fi.erasure.block_size);
let put_object_size = known_put_object_storage_size(data.size());
let is_inline_buffer =
runtime_sources::storage_class_should_inline(erasure.shard_file_size(put_object_size), opts.versioned);
let shard_file_size = erasure.shard_file_size(put_object_size);
let shard_size = erasure.shard_size();
let writer_setup_stage_start = Instant::now();
let writer_futs: Vec<_> = shuffle_disks
.iter()
.map(|disk_op| {
let tmp_obj = tmp_object.clone();
async move {
if let Some(disk) = disk_op
&& disk.is_online().await
{
match create_bitrot_writer(
is_inline_buffer,
Some(disk),
RUSTFS_META_TMP_BUCKET,
&tmp_obj,
shard_file_size,
shard_size,
HashAlgorithm::HighwayHash256S,
)
.await
{
Ok(writer) => (Some(writer), None),
Err(err) => {
warn!(
event = EVENT_SET_DISK_WRITE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
disk = ?disk,
state = "bitrot_writer_skipped",
error = ?err,
"Set disk bitrot writer skipped"
);
(None, Some(err))
}
}
} else {
(None, Some(DiskError::DiskNotFound))
}
}
})
.collect();
let writer_results = join_all(writer_futs).await;
let mut writers = Vec::with_capacity(writer_results.len());
let mut errors = Vec::with_capacity(writer_results.len());
for (w, e) in writer_results {
writers.push(w);
errors.push(e);
}
let writer_setup_ms = writer_setup_stage_start.elapsed().as_millis() as u64;
rustfs_io_metrics::record_put_object_stage_duration("set_disk_writer_setup", writer_setup_ms as f64);
let nil_count = errors.iter().filter(|&e| e.is_none()).count();
if nil_count < write_quorum {
error!(
event = EVENT_SET_DISK_WRITE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket,
object,
write_quorum,
available_writers = nil_count,
state = "write_quorum_unavailable",
error = ?errors,
"Set disk write quorum unavailable"
);
if let Some(write_err) = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, write_quorum) {
return Err(to_object_err(write_err.into(), vec![bucket, object]));
}
return Err(Error::other(format!("not enough disks to write: {errors:?}")));
}
let stream = mem::replace(
&mut data.stream,
HashReader::from_stream(Cursor::new(Vec::new()), 0, 0, None, None, false)?,
);
let write_path = classify_put_write_path(is_inline_buffer, put_object_size, fi.erasure.block_size);
rustfs_io_metrics::record_put_object_path(write_path.metric_label());
let encode_stage_start = Instant::now();
let (reader, w_size) = match write_path {
SmallWritePath::Inline => match Arc::new(erasure)
.encode_inline_small(stream, &mut writers, write_quorum)
.await
{
Ok((r, w)) => (r, w),
Err(e) => {
error!("encode_inline_small err {:?}", e);
return Err(e.into());
}
},
SmallWritePath::SingleBlockNonInline => match Arc::new(erasure)
.encode_single_block_non_inline(stream, &mut writers, write_quorum)
.await
{
Ok((r, w)) => (r, w),
Err(e) => {
error!("encode_single_block_non_inline err {:?}", e);
return Err(e.into());
}
},
SmallWritePath::PipelineBatchedLarge => {
match Arc::new(erasure).encode_batched(stream, &mut writers, write_quorum).await {
Ok((r, w)) => (r, w),
Err(e) => {
error!("encode_batched err {:?}", e);
return Err(e.into());
}
}
}
SmallWritePath::Pipeline => match Arc::new(erasure).encode(stream, &mut writers, write_quorum).await {
Ok((r, w)) => (r, w),
Err(e) => {
error!("encode err {:?}", e);
return Err(e.into());
}
},
};
let encode_ms = encode_stage_start.elapsed().as_millis() as u64;
rustfs_io_metrics::record_put_object_stage_duration("set_disk_encode", encode_ms as f64);
let _ = mem::replace(&mut data.stream, reader);
// if let Err(err) = close_bitrot_writers(&mut writers).await {
// error!("close_bitrot_writers err {:?}", err);
// }
if (w_size as i64) < data.size() {
warn!(
event = EVENT_SET_DISK_WRITE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket,
object,
written_size = w_size,
expected_size = data.size(),
state = "short_write",
"Set disk write produced fewer bytes than expected"
);
return Err(Error::other(format!(
"put_object write size < data.size(), w_size={}, data.size={}",
w_size,
data.size()
)));
}
if contains_key_str(&user_defined, SUFFIX_COMPRESSION) {
insert_str(&mut user_defined, SUFFIX_COMPRESSION_SIZE, w_size.to_string());
}
let index_op = data
.stream
.try_get_index()
.map(crate::io_support::rio::compression_index_storage_bytes);
//TODO: userDefined
let mut etag = data.stream.try_resolve_etag().unwrap_or_default();
if let Some(ref tag) = opts.preserve_etag {
etag = tag.clone();
}
user_defined.insert("etag".to_owned(), etag.clone());
if !user_defined.contains_key("content-type") {
// get content-type
}
let mut actual_size = data.actual_size();
if actual_size < 0 {
let is_compressed = fi.is_compressed();
if !is_compressed {
actual_size = w_size as i64;
}
}
if fi.checksum.is_none()
&& let Some(content_hash) = data.as_hash_reader().content_hash()
{
fi.checksum = Some(content_hash.to_bytes(&[]));
}
if let Some(sc) = user_defined.get(AMZ_STORAGE_CLASS)
&& sc == storageclass::STANDARD
{
let _ = user_defined.remove(AMZ_STORAGE_CLASS);
}
let mod_time = if let Some(mod_time) = opts.mod_time {
Some(mod_time)
} else {
Some(OffsetDateTime::now_utc())
};
for (i, pfi) in parts_metadatas.iter_mut().enumerate() {
pfi.metadata = user_defined.clone();
if is_inline_buffer {
if let Some(writer) = writers[i].take() {
pfi.data = Some(writer.into_inline_data().map(Bytes::from).unwrap_or_default());
}
pfi.set_inline_data();
}
pfi.mod_time = mod_time;
pfi.size = w_size as i64;
pfi.versioned = opts.versioned || opts.version_suspended;
pfi.add_object_part(1, etag.clone(), w_size, mod_time, actual_size, index_op.clone(), None);
pfi.checksum = fi.checksum.clone();
if opts.data_movement {
pfi.set_data_moved();
}
}
drop(writers); // drop writers to close all files, this is to prevent FileAccessDenied errors when renaming data
if !opts.no_lock && object_lock_guard.is_none() {
object_lock_guard = Some(self.acquire_write_lock_diag("put_object_commit", bucket, object).await?);
}
let rename_stage_start = Instant::now();
let (online_disks, _, op_old_dir, cleanup_disks) = Self::rename_data(
&shuffle_disks,
RUSTFS_META_TMP_BUCKET,
tmp_dir.as_str(),
&parts_metadatas,
bucket,
object,
write_quorum,
)
.await?;
let rename_stage_ms = rename_stage_start.elapsed().as_millis() as u64;
rustfs_io_metrics::record_put_object_stage_duration("set_disk_rename", rename_stage_ms as f64);
if (rename_stage_ms as u128) >= SET_DISK_COMMIT_TAIL_WARN_THRESHOLD_MS {
warn!(
event = EVENT_SET_DISK_COMMIT_TAIL_SLOW,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
stage = "rename_data",
bucket = %bucket,
object = %object,
tmp_dir = %tmp_dir,
duration_ms = { rename_stage_ms },
write_quorum,
state = "slow",
"SetDisk commit tail stage is slow"
);
}
let mut cleanup_stage_ms: Option<u64> = None;
if let Some(old_dir) = op_old_dir {
let cleanup_stage_start = Instant::now();
self.commit_rename_data_dir(&cleanup_disks, bucket, object, &old_dir.to_string(), write_quorum)
.await?;
let cleanup_ms = cleanup_stage_start.elapsed().as_millis() as u64;
cleanup_stage_ms = Some(cleanup_ms);
rustfs_io_metrics::record_put_object_stage_duration("set_disk_old_data_cleanup", cleanup_ms as f64);
if (cleanup_ms as u128) >= SET_DISK_COMMIT_TAIL_WARN_THRESHOLD_MS {
warn!(
event = EVENT_SET_DISK_COMMIT_TAIL_SLOW,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
stage = "commit_rename_data_dir",
bucket = %bucket,
object = %object,
tmp_dir = %tmp_dir,
old_dir = %old_dir,
duration_ms = cleanup_ms,
write_quorum,
state = "slow",
"SetDisk commit tail stage is slow"
);
}
}
drop(object_lock_guard); // drop object lock guard to release the lock
for (i, op_disk) in online_disks.iter().enumerate() {
if let Some(disk) = op_disk
&& disk.is_online().await
{
fi = parts_metadatas[i].clone();
break;
}
}
if fi.is_compressed() {
record_compression_total_memory(actual_size as u64, w_size as u64).await;
}
record_capacity_scope_if_needed(opts.capacity_scope_token, &online_disks);
fi.replication_state_internal = Some(replication_state_to_filemeta(&opts.put_replication_state()));
fi.is_latest = true;
if issue3031_diag_enabled() {
let online_success_count = online_disks.iter().filter(|disk| disk.is_some()).count();
warn!(
target: "rustfs_ecstore::set_disk",
bucket = %bucket,
object = %object,
tmp_dir = %tmp_dir,
data_dir = ?fi.data_dir,
write_quorum,
online_success_count,
op_old_dir = ?op_old_dir,
"issue3031_put_object_commit_succeeded"
);
}
let total_commit_tail_ms = rename_stage_start.elapsed().as_millis();
if total_commit_tail_ms >= SET_DISK_COMMIT_TAIL_WARN_THRESHOLD_MS {
warn!(
event = EVENT_SET_DISK_COMMIT_TAIL_SLOW,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
stage = "put_object_commit_tail",
bucket = %bucket,
object = %object,
tmp_dir = %tmp_dir,
duration_ms = total_commit_tail_ms as u64,
write_quorum,
state = "slow",
"SetDisk commit tail is slow"
);
}
if issue3031_diag_enabled() {
warn!(
event = EVENT_SET_DISK_PUT_OBJECT_STAGE_SUMMARY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket = %bucket,
object = %object,
write_quorum,
write_path = write_path.metric_label(),
writer_setup_ms,
encode_ms,
rename_ms = rename_stage_ms,
cleanup_ms = cleanup_stage_ms.unwrap_or_default(),
cleanup_present = cleanup_stage_ms.is_some(),
commit_tail_ms = total_commit_tail_ms as u64,
result = "success",
"SetDisk put_object stage summary"
);
}
Ok(ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended))
}
.await;
if issue3031_diag_enabled()
&& let Err(err) = &result
{
let stage_hint = if err.to_string().contains("not enough disks to write") {
"writer_setup_or_quorum"
} else {
"unknown"
};
warn!(
event = EVENT_SET_DISK_PUT_OBJECT_STAGE_SUMMARY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket = %bucket,
object = %object,
result = "error",
stage_hint,
error = %err,
"SetDisk put_object stage summary"
);
}
if result.is_ok() {
self.invalidate_get_object_metadata_cache(bucket, object).await;
}
if issue3031_diag_enabled() {
warn!(
target: "rustfs_ecstore::set_disk",
bucket = %bucket,
object = %object,
tmp_dir = %tmp_dir,
result = ?result.as_ref().map(|_| ()).map_err(|err| err.to_string()),
"issue3031_put_object_tmp_cleanup_start"
);
}
if let Err(err) = self.delete_all(RUSTFS_META_TMP_BUCKET, &tmp_dir).await {
warn!(tmp_dir = %tmp_dir, error = ?err, "failed to cleanup put_object temporary data");
} else if issue3031_diag_enabled() {
warn!(
target: "rustfs_ecstore::set_disk",
bucket = %bucket,
object = %object,
tmp_dir = %tmp_dir,
"issue3031_put_object_tmp_cleanup_done"
);
}
result
}
}
impl SetDisks {
async fn acquire_dist_delete_object_locks_batch(
&self,
batch: &rustfs_lock::BatchLockRequest,
) -> (HashMap<(String, String), String>, HashSet<String>, Vec<Vec<rustfs_lock::LockId>>) {
let requests: Vec<rustfs_lock::LockRequest> = batch
.requests
.iter()
.map(|req| {
rustfs_lock::LockRequest::new(req.key.clone(), rustfs_lock::LockType::Exclusive, self.locker_owner.clone())
.with_acquire_timeout(get_lock_acquire_timeout())
.with_ttl(rustfs_lock::fast_lock::DEFAULT_LOCK_TIMEOUT)
})
.collect();
let write_quorum = if self.lockers.len() > 1 {
(self.lockers.len() / 2) + 1
} else {
1
};
let mut lock_ids_by_object: Vec<Vec<(usize, rustfs_lock::LockId)>> = vec![Vec::new(); requests.len()];
let mut errors_by_object: Vec<Option<String>> = vec![None; requests.len()];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ObjectLockResolution {
Pending,
Succeeded,
Failed,
}
let mut resolution_by_object = vec![ObjectLockResolution::Pending; requests.len()];
let mut pending_clients = self.lockers.len();
let mut unresolved_objects = requests.len();
let mut cleanup_lock_ids_by_client = vec![Vec::new(); self.lockers.len()];
let mut pending = tokio::task::JoinSet::new();
for (client_idx, client) in self.lockers.iter().cloned().enumerate() {
let requests = requests.clone();
pending.spawn(async move { (client_idx, client.acquire_locks_batch(&requests).await) });
}
while unresolved_objects > 0 {
let Some(join_result) = pending.join_next().await else {
break;
};
pending_clients = pending_clients.saturating_sub(1);
match join_result {
Ok((client_idx, Ok(responses))) => {
for (req_idx, request) in requests.iter().enumerate() {
let response = responses.get(req_idx);
match resolution_by_object[req_idx] {
ObjectLockResolution::Pending => match response {
Some(response) if response.success => {
let lock_id = response
.lock_info
.as_ref()
.map(|lock_info| lock_info.id.clone())
.unwrap_or_else(|| request.lock_id.clone());
lock_ids_by_object[req_idx].push((client_idx, lock_id));
}
Some(response) => {
if errors_by_object[req_idx].is_none() {
errors_by_object[req_idx] = Some(
response
.error
.clone()
.unwrap_or_else(|| "distributed lock acquisition failed".to_string()),
);
}
}
None => {
if errors_by_object[req_idx].is_none() {
errors_by_object[req_idx] =
Some(format!("client {client_idx} returned incomplete batch lock response"));
}
}
},
ObjectLockResolution::Succeeded | ObjectLockResolution::Failed => {
if let Some(response) = response
&& response.success
{
let lock_id = response
.lock_info
.as_ref()
.map(|lock_info| lock_info.id.clone())
.unwrap_or_else(|| request.lock_id.clone());
cleanup_lock_ids_by_client[client_idx].push(lock_id);
}
}
}
}
}
Ok((client_idx, Err(err))) => {
for (req_idx, error) in errors_by_object.iter_mut().enumerate().take(requests.len()) {
if resolution_by_object[req_idx] == ObjectLockResolution::Pending && error.is_none() {
*error = Some(format!("client {client_idx} batch lock request failed: {err}"));
}
}
}
Err(err) => {
for (req_idx, error) in errors_by_object.iter_mut().enumerate().take(requests.len()) {
if resolution_by_object[req_idx] == ObjectLockResolution::Pending && error.is_none() {
*error = Some(format!("batch lock task join failed: {err}"));
}
}
}
}
for req_idx in 0..requests.len() {
if resolution_by_object[req_idx] != ObjectLockResolution::Pending {
continue;
}
let success_count = lock_ids_by_object[req_idx].len();
if success_count >= write_quorum {
resolution_by_object[req_idx] = ObjectLockResolution::Succeeded;
unresolved_objects -= 1;
} else if success_count + pending_clients < write_quorum {
resolution_by_object[req_idx] = ObjectLockResolution::Failed;
unresolved_objects -= 1;
}
}
}
if issue3031_diag_enabled() {
let succeeded_count = resolution_by_object
.iter()
.filter(|resolution| matches!(resolution, ObjectLockResolution::Succeeded))
.count();
let failed_count = resolution_by_object
.iter()
.filter(|resolution| matches!(resolution, ObjectLockResolution::Failed))
.count();
let pending_count = resolution_by_object
.iter()
.filter(|resolution| matches!(resolution, ObjectLockResolution::Pending))
.count();
warn!(
target: "rustfs_ecstore::set_disk",
request_count = requests.len(),
locker_count = self.lockers.len(),
write_quorum,
succeeded_count,
failed_count,
pending_count,
pending_clients,
errors_by_object = ?errors_by_object,
"issue3031_delete_objects_dist_batch_lock_summary"
);
}
if !pending.is_empty() {
let cleanup_requests = requests.clone();
let lockers = self.lockers.clone();
let handle = tokio::spawn(
async move {
let mut late_lock_ids_by_client = vec![Vec::new(); lockers.len()];
let mut pending = pending;
while let Some(join_result) = pending.join_next().await {
match join_result {
Ok((client_idx, Ok(responses))) => {
for (req_idx, request) in cleanup_requests.iter().enumerate() {
if let Some(response) = responses.get(req_idx)
&& response.success
{
let lock_id = response
.lock_info
.as_ref()
.map(|lock_info| lock_info.id.clone())
.unwrap_or_else(|| request.lock_id.clone());
if let Some(client_locks) = late_lock_ids_by_client.get_mut(client_idx) {
client_locks.push(lock_id);
}
}
}
}
Ok((_client_idx, Err(err))) => {
warn!("late distributed delete lock batch request failed: {}", err);
}
Err(err) => {
warn!("late distributed delete lock batch task join failed: {}", err);
}
}
}
join_all(lockers.iter().cloned().enumerate().filter_map(|(client_idx, client)| {
let lock_ids = late_lock_ids_by_client.get(client_idx).cloned().unwrap_or_default();
if lock_ids.is_empty() {
None
} else {
Some(async move {
if let Err(err) = client.release_locks_batch(&lock_ids).await {
warn!(
client_idx,
lock_count = lock_ids.len(),
"failed to cleanup late distributed delete locks in batch: {}",
err
);
}
})
}
}))
.await;
}
.instrument(tracing::Span::current()),
);
drop(handle);
}
let mut failed_map = HashMap::new();
let mut locked_objects = HashSet::new();
let mut held_lock_ids_by_client = vec![Vec::new(); self.lockers.len()];
let mut rollback_lock_ids_by_client = vec![Vec::new(); self.lockers.len()];
for (req_idx, req) in batch.requests.iter().enumerate() {
let success_count = lock_ids_by_object[req_idx].len();
match resolution_by_object[req_idx] {
ObjectLockResolution::Succeeded => {
for (client_idx, lock_id) in lock_ids_by_object[req_idx].drain(..) {
held_lock_ids_by_client[client_idx].push(lock_id);
}
locked_objects.insert(req.key.object.as_ref().to_string());
}
ObjectLockResolution::Pending | ObjectLockResolution::Failed => {
for (client_idx, lock_id) in lock_ids_by_object[req_idx].drain(..) {
rollback_lock_ids_by_client[client_idx].push(lock_id);
}
failed_map.insert(
(req.key.bucket.as_ref().to_string(), req.key.object.as_ref().to_string()),
errors_by_object[req_idx].clone().unwrap_or_else(|| {
format!("failed to acquire distributed delete lock quorum: {success_count}/{write_quorum}")
}),
);
}
}
}
for (client_idx, cleanup_ids) in cleanup_lock_ids_by_client.into_iter().enumerate() {
rollback_lock_ids_by_client[client_idx].extend(cleanup_ids);
}
self.release_dist_delete_object_locks_batch(rollback_lock_ids_by_client).await;
(failed_map, locked_objects, held_lock_ids_by_client)
}
async fn release_dist_delete_object_locks_batch(&self, lock_ids_by_client: Vec<Vec<rustfs_lock::LockId>>) {
join_all(self.lockers.iter().cloned().enumerate().filter_map(|(client_idx, client)| {
let lock_ids = lock_ids_by_client.get(client_idx).cloned().unwrap_or_default();
if lock_ids.is_empty() {
None
} else {
Some(async move {
if let Err(err) = client.release_locks_batch(&lock_ids).await {
warn!(
client_idx,
lock_count = lock_ids.len(),
"failed to release distributed delete locks in batch: {}",
err
);
}
})
}
}))
.await;
}
}
impl SetDisks {
pub(crate) async fn storage_info_snapshot(&self) -> rustfs_madmin::StorageInfo {
let disks = self.get_disks_internal().await;
get_storage_info(&disks, &self.set_endpoints).await
}
pub(crate) async fn local_storage_info_snapshot(&self) -> rustfs_madmin::StorageInfo {
let disks = self.get_disks_internal().await;
let mut local_disks: Vec<Option<DiskStore>> = Vec::new();
let mut local_endpoints = Vec::new();
for (i, ep) in self.set_endpoints.iter().enumerate() {
if ep.is_local {
local_disks.push(disks[i].clone());
local_endpoints.push(ep.clone());
}
}
get_storage_info(&local_disks, &local_endpoints).await
}
pub(crate) async fn disk_inventory(&self) -> Vec<Option<DiskStore>> {
self.get_disks_internal().await
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::namespace::NamespaceLocking for SetDisks {
type Error = Error;
type NamespaceLock = NamespaceLockWrapper;
#[tracing::instrument(skip(self))]
async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result<NamespaceLockWrapper> {
let set_lock = if runtime_sources::setup_is_dist_erasure().await {
// Calculate quorum based on lockers count (majority)
let lockers_count = self.lockers.len();
let write_quorum = if lockers_count > 1 { (lockers_count / 2) + 1 } else { 1 };
NamespaceLock::with_clients_and_quorum(
format!("set-{}-{}", self.pool_index, self.set_index),
self.lockers.clone(),
write_quorum,
)
} else {
NamespaceLock::Local(LocalLock::new(
format!("set-{}-{}", self.pool_index, self.set_index),
self.local_lock_manager.clone(),
))
};
let resource = ObjectKey {
bucket: Arc::from(bucket),
object: Arc::from(object),
version: None,
};
Ok(NamespaceLockWrapper::new(set_lock, resource, self.locker_owner.clone()))
}
}
#[async_trait::async_trait]
impl BucketOperations for SetDisks {
type Error = Error;
#[tracing::instrument(skip(self))]
async fn make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()> {
let disks = self.disk_inventory().await;
let write_quorum = (disks.len() / 2) + 1;
let force_create = opts.force_create;
let mut futures = Vec::with_capacity(disks.len());
for disk in disks {
let bucket = bucket.to_string();
futures.push(async move {
match disk {
Some(disk) => match disk.make_volume(&bucket).await {
Ok(()) => Ok(()),
Err(err) if force_create && matches!(err, DiskError::VolumeExists) => Ok(()),
Err(err) => Err(err),
},
None => Err(DiskError::DiskNotFound),
}
});
}
let results = join_all(futures).await;
let errs = results
.into_iter()
.map(|result| result.err())
.collect::<Vec<Option<DiskError>>>();
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
return Err(err.into());
}
Ok(())
}
#[tracing::instrument(skip(self))]
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
let disks = self.disk_inventory().await;
let write_quorum = (disks.len() / 2) + 1;
let mut futures = Vec::with_capacity(disks.len());
for disk in disks {
let bucket = bucket.to_string();
futures.push(async move {
match disk {
Some(disk) => disk.stat_volume(&bucket).await,
None => Err(DiskError::DiskNotFound),
}
});
}
let results = join_all(futures).await;
let mut infos = Vec::with_capacity(results.len());
let mut errs = Vec::with_capacity(results.len());
for result in results {
match result {
Ok(info) => {
infos.push(Some(info));
errs.push(None);
}
Err(err) => {
infos.push(None);
errs.push(Some(err));
}
}
}
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
return Err(err.into());
}
let mut versioning = false;
let mut object_locking = false;
if let Ok(sys) = metadata_sys::get(bucket).await {
versioning = sys.versioning();
object_locking = sys.object_locking();
}
infos
.into_iter()
.flatten()
.next()
.map(|info| BucketInfo {
name: info.name,
created: info.created,
versioning,
object_locking,
..Default::default()
})
.ok_or(Error::VolumeNotFound)
}
#[tracing::instrument(skip(self))]
async fn list_bucket(&self, _opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
let disks = self.disk_inventory().await;
let write_quorum = (disks.len() / 2) + 1;
let mut futures = Vec::with_capacity(disks.len());
for disk in disks {
futures.push(async move {
match disk {
Some(disk) => disk.list_volumes().await,
None => Err(DiskError::DiskNotFound),
}
});
}
let results = join_all(futures).await;
let mut infos = Vec::with_capacity(results.len());
let mut errs = Vec::with_capacity(results.len());
for result in results {
match result {
Ok(volumes) => {
infos.push(Some(volumes));
errs.push(None);
}
Err(err) => {
infos.push(None);
errs.push(Some(err));
}
}
}
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
return Err(err.into());
}
let mut counts: HashMap<String, (usize, BucketInfo)> = HashMap::new();
for volumes in infos.into_iter().flatten() {
for volume in volumes {
if is_reserved_or_invalid_bucket(&volume.name, false) {
continue;
}
let entry = counts.entry(volume.name.clone()).or_insert((
0,
BucketInfo {
name: volume.name.clone(),
created: volume.created,
..Default::default()
},
));
entry.0 += 1;
}
}
let mut buckets = counts
.into_values()
.filter_map(|(count, bucket)| (count >= write_quorum).then_some(bucket))
.collect::<Vec<_>>();
buckets.sort_by(|left, right| left.name.cmp(&right.name));
Ok(buckets)
}
#[tracing::instrument(skip(self))]
async fn delete_bucket(&self, bucket: &str, _opts: &DeleteBucketOptions) -> Result<()> {
let disks = self.disk_inventory().await;
let write_quorum = (disks.len() / 2) + 1;
let mut futures = Vec::with_capacity(disks.len());
for disk in disks.iter().cloned() {
let bucket = bucket.to_string();
futures.push(async move {
match disk {
Some(disk) => disk.delete_volume(&bucket).await,
None => Err(DiskError::DiskNotFound),
}
});
}
let results = join_all(futures).await;
let mut errs = Vec::with_capacity(results.len());
let mut recreate = false;
for result in results {
match result {
Ok(()) => errs.push(None),
Err(err) => {
if matches!(err, DiskError::VolumeNotEmpty) {
recreate = true;
}
errs.push(Some(err));
}
}
}
if recreate {
for (index, err) in errs.iter().enumerate() {
if err.is_none()
&& let Some(Some(disk)) = disks.get(index)
{
let _ = disk.make_volume(bucket).await;
}
}
return Err(Error::VolumeNotEmpty);
}
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
return Err(err.into());
}
Ok(())
}
}
fn check_object_lock_retention_update(bucket: &str, object: &str, obj_info: &ObjectInfo, opts: &ObjectOptions) -> Result<()> {
if let Some(retention) = &opts.object_lock_retention
&& check_retention_for_modification(
&obj_info.user_defined,
retention.mode.as_deref(),
retention.retain_until,
retention.bypass_governance,
)
.is_some()
{
return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object.to_string()));
}
Ok(())
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
type Error = Error;
type ObjectInfo = ObjectInfo;
type ObjectOptions = ObjectOptions;
type FileInfo = FileInfo;
type ObjectToDelete = ObjectToDelete;
type DeletedObject = DeletedObject;
#[tracing::instrument(skip(self))]
async fn copy_object(
&self,
src_bucket: &str,
src_object: &str,
dst_bucket: &str,
dst_object: &str,
src_info: &mut ObjectInfo,
src_opts: &ObjectOptions,
dst_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
if !src_info.metadata_only {
if path_join_buf(&[src_bucket, src_object]) != path_join_buf(&[dst_bucket, dst_object]) {
return Err(StorageError::NotImplemented);
}
// Self-copy with a data reader: write tier data back locally (de-tiering).
// Handles `mc cp --storage-class STANDARD obj obj` on a transitioned object.
if let Some(mut put_reader) = src_info.put_object_reader.take() {
return self.put_object(dst_bucket, dst_object, &mut put_reader, dst_opts).await;
}
// Same-key tiered copy without a pre-fetched reader: fall through to the metadata
// path so the caller gets a disk/quorum error rather than NotImplemented.
}
if path_join_buf(&[src_bucket, src_object]) != path_join_buf(&[dst_bucket, dst_object]) {
return Err(StorageError::NotImplemented);
}
let _lock_guard = if dst_opts.no_lock {
None
} else {
Some(
self.acquire_write_lock_diag("copy_object_metadata", dst_bucket, dst_object)
.await?,
)
};
self.invalidate_get_object_metadata_cache(dst_bucket, dst_object).await;
if dst_opts.http_preconditions.is_some()
&& let Some(err) = self.check_write_precondition(dst_bucket, dst_object, dst_opts).await
{
return Err(err);
}
let disks = self.get_disks_internal().await;
let (mut metas, errs) = {
if let Some(vid) = &src_opts.version_id {
Self::read_all_fileinfo(&disks, "", src_bucket, src_object, vid, true, false, false).await?
} else {
Self::read_all_xl(&disks, src_bucket, src_object, true, false).await
}
};
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
Ok((r, w)) => (
usize::try_from(r)
.map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![src_bucket, src_object]))?,
usize::try_from(w)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![src_bucket, src_object]))?,
),
Err(mut err) => {
if err == DiskError::ErasureReadQuorum
&& !src_bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(src_bucket, src_object, &metas, &errs, &HashMap::new(), src_opts.clone())
.await
.is_ok()
{
if src_opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![src_bucket, src_object]));
}
};
let src_version_id = src_opts.version_id.as_deref().unwrap_or_default();
let (online_disks, mut fi, _) =
Self::select_valid_fileinfo(&disks, &metas, &errs, src_version_id, read_quorum, write_quorum)
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
if fi.deleted {
if src_opts.version_id.is_none() {
return Err(to_object_err(Error::FileNotFound, vec![src_bucket, src_object]));
}
return Err(to_object_err(Error::MethodNotAllowed, vec![src_bucket, src_object]));
}
let version_id = {
if src_info.version_only {
if let Some(vid) = &dst_opts.version_id {
Some(Uuid::parse_str(vid)?)
} else {
Some(Uuid::new_v4())
}
} else {
src_info.version_id
}
};
fi.metadata = (*src_info.user_defined).clone();
if let Some(etag) = &src_info.etag {
fi.metadata.insert("etag".to_owned(), etag.clone());
}
let mod_time = OffsetDateTime::now_utc();
fi.mod_time = Some(mod_time);
fi.version_id = version_id;
fi.versioned = src_opts.versioned || src_opts.version_suspended;
if src_info.version_only {
let inline_data = fi.inline_data();
for fi in metas.iter_mut() {
if fi.is_valid() {
fi.metadata = (*src_info.user_defined).clone();
if let Some(etag) = &src_info.etag {
fi.metadata.insert("etag".to_owned(), etag.clone());
}
fi.mod_time = Some(mod_time);
fi.version_id = version_id;
fi.versioned = src_opts.versioned || src_opts.version_suspended;
if !fi.inline_data() {
fi.data = None;
}
if inline_data {
fi.set_inline_data();
}
}
}
Self::write_unique_file_info(&online_disks, "", src_bucket, src_object, &metas, write_quorum)
.await
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
} else {
self.update_object_meta_with_opts(
src_bucket,
src_object,
fi.clone(),
&online_disks,
&UpdateMetadataOpts {
replace_user_metadata: true,
..Default::default()
},
)
.await
.map_err(|e| to_object_err(e.into(), vec![src_bucket, src_object]))?;
}
self.invalidate_get_object_metadata_cache(src_bucket, src_object).await;
Ok(ObjectInfo::from_file_info(
&fi,
src_bucket,
src_object,
src_opts.versioned || src_opts.version_suspended,
))
}
#[tracing::instrument(skip(self))]
async fn delete_object_version(&self, bucket: &str, object: &str, fi: &FileInfo, force_del_marker: bool) -> Result<()> {
let disks = self.disk_inventory().await;
let write_quorum = disks.len() / 2 + 1;
let mut futures = Vec::with_capacity(disks.len());
let mut errs = Vec::with_capacity(disks.len());
for disk in disks.iter() {
futures.push(async move {
if let Some(disk) = disk {
match disk
.delete_version(bucket, object, fi.clone(), force_del_marker, DeleteOptions::default())
.await
{
Ok(r) => Ok(r),
Err(e) => Err(e),
}
} else {
Err(DiskError::DiskNotFound)
}
});
}
let results = join_all(futures).await;
for result in results {
match result {
Ok(_) => {
errs.push(None);
}
Err(e) => {
errs.push(Some(e));
}
}
}
resolve_tiered_decommission_write_quorum_result(&errs, write_quorum, bucket, object)
}
#[tracing::instrument(skip(self))]
async fn delete_objects(
&self,
bucket: &str,
objects: Vec<ObjectToDelete>,
opts: ObjectOptions,
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
for object in &objects {
self.invalidate_get_object_metadata_cache(bucket, &object.object_name).await;
}
// Default return value
let mut del_objects = vec![DeletedObject::default(); objects.len()];
let mut del_errs = Vec::with_capacity(objects.len());
for _ in 0..objects.len() {
del_errs.push(None)
}
// Acquire locks in batch mode (best effort, matching previous behavior)
let mut batch = rustfs_lock::BatchLockRequest::new(self.locker_owner.as_str()).with_all_or_nothing(false);
let mut unique_objects: HashSet<String> = HashSet::new();
for dobj in &objects {
if unique_objects.insert(dobj.object_name.clone()) {
batch = batch.add_write_lock(ObjectKey::new(bucket, dobj.object_name.clone()));
}
}
let unique_lock_count = batch.requests.len();
let mut failed_map = HashMap::new();
let mut _local_batch_guards: Vec<FastLockGuard> = Vec::with_capacity(batch.requests.len());
let mut locked_objects = HashSet::new();
let dist_erasure = runtime_sources::setup_is_dist_erasure().await;
let mut dist_batch_lock_ids = vec![Vec::new(); self.lockers.len()];
if dist_erasure {
(failed_map, locked_objects, dist_batch_lock_ids) = self.acquire_dist_delete_object_locks_batch(&batch).await;
} else {
let batch_result = self.local_lock_manager.acquire_locks_batch(batch).await;
_local_batch_guards = batch_result.guards;
for key in batch_result.successful_locks {
locked_objects.insert(key.object.as_ref().to_string());
}
for (key, err) in batch_result.failed_locks {
failed_map.insert((key.bucket.as_ref().to_string(), key.object.as_ref().to_string()), format!("{err:?}"));
}
}
if issue3031_diag_enabled() {
let failed_lock_count = failed_map.len();
let locked_object_count = locked_objects.len();
let dist_lock_id_count = dist_batch_lock_ids.iter().map(Vec::len).sum::<usize>();
warn!(
target: "rustfs_ecstore::set_disk",
bucket = %bucket,
requested_object_count = objects.len(),
unique_lock_count,
locked_object_count,
failed_lock_count,
dist_erasure,
dist_lock_id_count,
failed_objects = ?failed_map.keys().collect::<Vec<_>>(),
"issue3031_delete_objects_lock_batch_context"
);
}
// Mark failures for objects that could not be locked
for (i, dobj) in objects.iter().enumerate() {
if let Some(err) = failed_map.get(&(bucket.to_string(), dobj.object_name.clone())) {
del_errs[i] = Some(Error::other(err.to_string()));
}
}
let ver_cfg = BucketVersioningSys::get(bucket).await.unwrap_or_default();
let mut vers_map: HashMap<&String, FileInfoVersions> = HashMap::new();
for (i, dobj) in objects.iter().enumerate() {
let explicit_null_version = is_explicit_null_version(dobj.version_id);
let mut vr = FileInfo {
name: dobj.object_name.clone(),
version_id: delete_file_info_version_id(dobj.version_id),
idx: i,
replication_state_internal: Some(dobj.replication_state()),
..Default::default()
};
vr.set_tier_free_version_id(&Uuid::new_v4().to_string());
// Delete
// del_objects[i].object_name.clone_from(&vr.name);
// del_objects[i].version_id = vr.version_id.map(|v| v.to_string());
if dobj.version_id.is_none() {
let (suspended, versioned) = (ver_cfg.suspended(), ver_cfg.prefix_enabled(dobj.object_name.as_str()));
if suspended || versioned {
vr.mod_time = Some(OffsetDateTime::now_utc());
vr.deleted = true;
if versioned {
vr.version_id = Some(Uuid::new_v4());
}
}
}
let v = {
if vers_map.contains_key(&dobj.object_name) {
let val = vers_map.get_mut(&dobj.object_name).unwrap();
val.versions.push(vr.clone());
val.clone()
} else {
FileInfoVersions {
name: vr.name.clone(),
versions: vec![vr.clone()],
..Default::default()
}
}
};
if vr.deleted {
del_objects[i] = DeletedObject {
delete_marker: vr.deleted,
delete_marker_version_id: vr.version_id,
delete_marker_mtime: vr.mod_time,
object_name: vr.name.clone(),
replication_state: vr.replication_state_internal.clone(),
..Default::default()
}
} else {
del_objects[i] = DeletedObject {
object_name: vr.name.clone(),
version_id: if explicit_null_version {
Some(Uuid::nil())
} else {
vr.version_id
},
replication_state: vr.replication_state_internal.clone(),
..Default::default()
}
}
// Only add to vers_map if we hold the lock
if locked_objects.contains(&dobj.object_name) {
vers_map.insert(&dobj.object_name, v);
}
}
let mut vers = Vec::with_capacity(vers_map.len());
for (_, mut fi_vers) in vers_map {
fi_vers.versions.sort_by_key(|a| a.deleted);
if let Some(index) = fi_vers.versions.iter().position(|fi| fi.deleted) {
fi_vers.versions.truncate(index + 1);
}
vers.push(fi_vers);
}
let disks = self.disks.read().await;
let disks = disks.clone();
let mut futures = Vec::with_capacity(disks.len());
// let mut errors = Vec::with_capacity(disks.len());
for disk in disks.iter() {
let vers = vers.clone();
futures.push(async move {
if let Some(disk) = disk {
disk.delete_versions(bucket, vers, DeleteOptions::default()).await
} else {
let mut errs = Vec::with_capacity(vers.len());
for _ in 0..vers.len() {
errs.push(Some(DiskError::DiskNotFound));
}
errs
}
});
}
let results = join_all(futures).await;
let mut del_obj_errs: Vec<Vec<Option<DiskError>>> = vec![vec![None; objects.len()]; disks.len()];
// For each disk delete all objects
for (disk_idx, errors) in results.into_iter().enumerate() {
// Deletion results for all objects
for idx in 0..vers.len() {
if errors[idx].is_some() {
for fi in vers[idx].versions.iter() {
del_obj_errs[disk_idx][fi.idx] = errors[idx].clone();
}
}
}
}
for obj_idx in 0..objects.len() {
let mut disk_err = vec![None; disks.len()];
for disk_idx in 0..disks.len() {
if del_obj_errs[disk_idx][obj_idx].is_some() {
disk_err[disk_idx] = del_obj_errs[disk_idx][obj_idx].clone();
}
}
let mut has_err = reduce_write_quorum_errs(&disk_err, OBJECT_OP_IGNORED_ERRS, disks.len() / 2 + 1);
if let Some(err) = has_err.clone() {
let er = err.into();
if (is_err_object_not_found(&er) || is_err_version_not_found(&er)) && !del_objects[obj_idx].delete_marker {
has_err = None;
}
} else {
del_objects[obj_idx].found = true;
}
if let Some(err) = has_err {
if del_objects[obj_idx].version_id.is_some() {
del_errs[obj_idx] = Some(to_object_err(
err.into(),
vec![
bucket,
&objects[obj_idx].object_name.clone(),
&objects[obj_idx].version_id.unwrap_or_default().to_string(),
],
));
} else {
del_errs[obj_idx] = Some(to_object_err(err.into(), vec![bucket, &objects[obj_idx].object_name.clone()]));
}
}
}
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
// TODO: add_partial
if dist_erasure {
self.release_dist_delete_object_locks_batch(dist_batch_lock_ids).await;
}
for (object, err) in objects.iter().zip(del_errs.iter()) {
if err.is_none() {
self.invalidate_get_object_metadata_cache(bucket, &object.object_name).await;
}
}
(del_objects, del_errs)
}
#[tracing::instrument(skip(self))]
async fn delete_object(&self, bucket: &str, object: &str, mut opts: ObjectOptions) -> Result<ObjectInfo> {
self.invalidate_get_object_metadata_cache(bucket, object).await;
// Guard lock for single object delete
let _lock_guard = if (!opts.delete_prefix || opts.delete_prefix_object) && !opts.no_lock {
Some(self.acquire_write_lock_diag("delete_object", bucket, object).await?)
} else {
None
};
if opts.delete_prefix {
self.delete_prefix(bucket, object)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
self.get_object_metadata_cache.invalidate_all();
return Ok(ObjectInfo::default());
}
// TODO: Lifecycle
let mut version_found = true;
let (mut goi, write_quorum, gerr) = self.get_object_info_and_quorum(bucket, object, &opts).await;
if let Some(err) = &gerr
&& goi.name.is_empty()
{
if should_force_delete_marker_for_missing_version(&opts) {
version_found = false;
} else {
return Err(err.clone());
}
}
let otd = ObjectToDelete {
object_name: object.to_string(),
version_id: opts
.version_id
.clone()
.map(|v| Uuid::parse_str(v.as_str()).ok().unwrap_or_default()),
..Default::default()
};
let dsc = if should_preserve_delete_replication_state(&opts) {
ReplicateDecision::default()
} else {
ReplicationObjectBridge::check_delete(bucket, &otd, &goi, &opts, gerr.map(|e| e.to_string())).await
};
if dsc.replicate_any() {
opts.set_delete_replication_state(dsc);
goi.replication_decision = opts
.delete_replication
.as_ref()
.map(|v| v.replicate_decision_str.clone())
.unwrap_or_default();
}
let (mark_delete, mut delete_marker) = resolve_delete_version_state(&opts, &goi, version_found);
let mod_time = if let Some(mt) = opts.mod_time {
mt
} else {
OffsetDateTime::now_utc()
};
let find_vid = Uuid::new_v4();
if mark_delete && (opts.versioned || opts.version_suspended) {
if !delete_marker {
delete_marker = opts.version_suspended && opts.version_id.is_none();
}
let mut fi = FileInfo {
name: object.to_string(),
deleted: delete_marker,
mark_deleted: mark_delete,
mod_time: Some(mod_time),
replication_state_internal: opts.delete_replication.as_ref().map(replication_state_to_filemeta),
..Default::default() // TODO: Transition
};
fi.set_tier_free_version_id(&find_vid.to_string());
if opts.skip_free_version {
fi.set_skip_tier_free_version();
}
fi.version_id = if let Some(vid) = opts.version_id.as_ref() {
Some(Uuid::parse_str(vid.as_str())?)
} else if opts.versioned {
Some(Uuid::new_v4())
} else {
None
};
self.delete_object_version(bucket, object, &fi, should_force_delete_marker_for_missing_version(&opts))
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
let disks = self.disk_inventory().await;
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
let mut oi = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
oi.replication_decision = goi.replication_decision;
self.invalidate_get_object_metadata_cache(bucket, object).await;
return Ok(oi);
}
// Create a single object deletion request
let mut dfi = FileInfo {
name: object.to_string(),
version_id: opts.version_id.as_ref().and_then(|v| Uuid::parse_str(v).ok()),
mark_deleted: mark_delete,
deleted: delete_marker,
mod_time: Some(mod_time),
replication_state_internal: opts.delete_replication.as_ref().map(replication_state_to_filemeta),
..Default::default()
};
dfi.set_tier_free_version_id(&find_vid.to_string());
if opts.skip_free_version {
dfi.set_skip_tier_free_version();
}
self.delete_object_version(bucket, object, &dfi, opts.delete_marker)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
let disks = self.disk_inventory().await;
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
let mut obj_info = ObjectInfo::from_file_info(&dfi, bucket, object, opts.versioned || opts.version_suspended);
obj_info.size = goi.size;
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(obj_info)
}
#[tracing::instrument(skip(self))]
async fn get_object_info(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
// Acquire a shared read-lock to protect consistency during info fetch
let _read_lock_guard = if !opts.no_lock {
Some(self.acquire_read_lock_diag("get_object_info", bucket, object).await?)
} else {
None
};
// Use the same full xl.meta read path as GetObject metadata resolution.
// This avoids HEAD/GetObject metadata visibility skew immediately after
// PutObject/CompleteMultipartUpload.
let (fi, _, _) = self
.get_object_fileinfo(bucket, object, opts, true)
.await
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
let oi = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
Ok(oi)
}
#[tracing::instrument(skip(self))]
async fn add_partial(&self, bucket: &str, object: &str, version_id: &str) -> Result<()> {
if let Err(e) =
rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
Some(HealChannelPriority::Normal),
Some(self.pool_index),
Some(self.set_index),
))
.await
{
warn!(
bucket,
object,
version_id,
error = %e,
"Failed to enqueue heal request for partial object"
);
}
Ok(())
}
#[tracing::instrument(skip(self))]
async fn put_object_metadata(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
self.invalidate_get_object_metadata_cache(bucket, object).await;
// TODO: nslock
// Guard lock for metadata update
let _lock_guard = if !opts.no_lock {
Some(self.acquire_write_lock_diag("put_object_metadata", bucket, object).await?)
} else {
None
};
let disks = self.get_disks_internal().await;
let (metas, errs) = {
if let Some(version_id) = &opts.version_id {
Self::read_all_fileinfo(&disks, "", bucket, object, version_id.to_string().as_str(), false, false, false).await?
} else {
Self::read_all_xl(&disks, bucket, object, false, false).await
}
};
let (read_quorum, write_quorum) = match Self::object_quorum_from_meta(&metas, &errs, self.default_parity_count) {
Ok((read_quorum, write_quorum)) => (read_quorum, write_quorum),
Err(mut err) => {
if err == DiskError::ErasureReadQuorum
&& !bucket.starts_with(RUSTFS_META_BUCKET)
&& self
.delete_if_dangling(bucket, object, &metas, &errs, &HashMap::new(), opts.clone())
.await
.is_ok()
{
if opts.version_id.is_some() {
err = DiskError::FileVersionNotFound
} else {
err = DiskError::FileNotFound
}
}
return Err(to_object_err(err.into(), vec![bucket, object]));
}
};
let read_quorum =
usize::try_from(read_quorum).map_err(|_| to_object_err(DiskError::ErasureReadQuorum.into(), vec![bucket, object]))?;
let write_quorum = usize::try_from(write_quorum)
.map_err(|_| to_object_err(DiskError::ErasureWriteQuorum.into(), vec![bucket, object]))?;
let version_id = opts.version_id.as_deref().unwrap_or_default();
let (online_disks, mut fi, _) = Self::select_valid_fileinfo(&disks, &metas, &errs, version_id, read_quorum, write_quorum)
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
if fi.deleted {
return Err(to_object_err(Error::MethodNotAllowed, vec![bucket, object]));
}
let obj_info = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
check_object_lock_retention_update(bucket, object, &obj_info, opts)?;
for (k, v) in obj_info.user_defined.iter() {
fi.metadata.insert(k.clone(), v.clone());
}
if let Some(mt) = &opts.eval_metadata {
for (k, v) in mt {
fi.metadata.insert(k.clone(), v.clone());
}
}
if opts.mod_time.is_some() {
fi.mod_time = opts.mod_time;
}
if let Some(ref version_id) = opts.version_id {
fi.version_id = Uuid::parse_str(version_id).ok();
}
self.update_object_meta(bucket, object, fi.clone(), &online_disks)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended))
}
#[tracing::instrument(skip(self))]
async fn get_object_tags(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<String> {
let oi = self.get_object_info(bucket, object, opts).await?;
Ok((*oi.user_tags).clone())
}
#[tracing::instrument(level = "debug", skip(self))]
async fn transition_object(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
let tier_config_mgr = runtime_sources::tier_config_mgr_handle();
let mut tier_config_mgr = tier_config_mgr.write().await;
let tgt_client = match tier_config_mgr.get_driver(&opts.transition.tier).await {
Ok(client) => client,
Err(err) => {
return Err(Error::other(format!("remote tier error: {err}")));
}
};
// Acquire write-lock early; hold for the whole transition operation scope
// if !opts.no_lock {
// let guard_opt = self
// .namespace_lock
// .lock_guard(object, &self.locker_owner, Duration::from_secs(5), Duration::from_secs(10))
// .await?;
// if guard_opt.is_none() {
// return Err(Error::other("can not get lock. please retry".to_string()));
// }
// _lock_guard = guard_opt;
// }
let (mut fi, meta_arr, online_disks) = self.get_object_fileinfo(bucket, object, opts, true).await?;
/*if err != nil {
return Err(to_object_err(err, vec![bucket, object]));
}*/
/*if fi.deleted {
if opts.version_id.is_none() {
return Err(to_object_err(DiskError::FileNotFound, vec![bucket, object]));
}
return Err(to_object_err(ERR_METHOD_NOT_ALLOWED, vec![bucket, object]));
}*/
// Normalize ETags by removing quotes before comparison (PR #592 compatibility)
let transition_etag = rustfs_utils::path::trim_etag(&opts.transition.etag);
let stored_etag = rustfs_utils::path::trim_etag(&get_raw_etag(&fi.metadata));
if let Some(mod_time1) = opts.mod_time {
if let Some(mod_time2) = fi.mod_time.as_ref() {
if mod_time1.unix_timestamp() != mod_time2.unix_timestamp()
/*|| transition_etag != stored_etag*/
{
return Err(to_object_err(Error::other(DiskError::FileNotFound), vec![bucket, object]));
}
} else {
return Err(Error::other("mod_time 2 error.".to_string()));
}
} else {
return Err(Error::other("mod_time 1 error.".to_string()));
}
if fi.transition_status == TRANSITION_COMPLETE {
return Ok(());
}
/*if fi.xlv1 {
if let Err(err) = self.heal_object(bucket, object, "", &HealOpts {no_lock: true, ..Default::default()}) {
return err.expect("err");
}
(fi, meta_arr, online_disks) = self.get_object_fileinfo(&bucket, &object, &opts, true);
if err != nil {
return to_object_err(err, vec![bucket, object]);
}
}*/
let dest_obj = gen_transition_objname(bucket);
if let Err(err) = dest_obj {
return Err(to_object_err(err, vec![]));
}
let dest_obj = dest_obj?;
let oi = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
let mut transition_meta = (*oi.user_defined).clone();
transition_meta.insert("name".to_string(), object.to_string());
if let Some(content_type) = oi.content_type.as_ref().filter(|value| !value.is_empty()) {
transition_meta.insert(CONTENT_TYPE.to_ascii_lowercase(), content_type.clone());
}
for header in [
CONTENT_ENCODING,
CONTENT_LANGUAGE,
CONTENT_DISPOSITION,
CACHE_CONTROL,
EXPIRES,
X_AMZ_OBJECT_LOCK_MODE.as_str(),
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str(),
X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str(),
] {
if let Some(value) = fi.metadata.lookup(header).filter(|value| !value.is_empty()) {
transition_meta.insert(header.to_ascii_lowercase(), value.to_string());
}
}
let (pr, mut pw) = tokio::io::duplex(fi.erasure.block_size);
let reader = ReaderImpl::ObjectBody(GetObjectReader {
stream: Box::new(pr),
object_info: oi,
buffered_body: None,
});
let cloned_bucket = bucket.to_string();
let cloned_object = object.to_string();
let cloned_fi = fi.clone();
let set_index = self.set_index;
let pool_index = self.pool_index;
let skip_verify = opts.skip_verify_bitrot;
let metrics_size_bucket = rustfs_io_metrics::get_object_size_bucket(cloned_fi.size);
tokio::spawn(async move {
if let Err(e) = Self::get_object_with_fileinfo(
&cloned_bucket,
&cloned_object,
0,
cloned_fi.size,
&mut pw,
cloned_fi,
meta_arr,
&online_disks,
set_index,
pool_index,
skip_verify,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
metrics_size_bucket,
)
.await
{
error!("get_object_with_fileinfo err {:?}", e);
};
});
let rv = tgt_client.put_with_meta(&dest_obj, reader, fi.size, transition_meta).await;
if let Err(err) = rv {
return Err(StorageError::Io(err));
}
let rv = rv?;
fi.transition_status = TRANSITION_COMPLETE.to_string();
fi.transitioned_objname = dest_obj;
fi.transition_tier = opts.transition.tier.clone();
fi.transition_version_id = if rv.is_empty() { None } else { Some(Uuid::parse_str(&rv)?) };
let event_name = EventName::LifecycleTransition.as_str();
let mut should_notify_transition = true;
let disks = self.disk_inventory().await;
if let Err(err) = self.delete_object_version(bucket, object, &fi, false).await {
should_notify_transition = false;
warn!(
bucket = bucket,
object = object,
error = ?err,
"transition completed on remote tier but source cleanup failed; skipping external lifecycle transition notification"
);
} else {
record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
}
for disk in disks.iter() {
if let Some(disk) = disk {
continue;
}
let _ = self
.add_partial(bucket, object, opts.version_id.as_deref().unwrap_or_default())
.await;
break;
}
if should_notify_transition {
let obj_info = ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended);
send_event(EventArgs {
event_name: event_name.to_string(),
bucket_name: bucket.to_string(),
object: obj_info,
user_agent: "Internal: [ILM-Transition]".to_string(),
host: runtime_sources::default_local_node_name(),
..Default::default()
});
}
//let tags = opts.lifecycle_audit_event.tags();
//auditLogLifecycle(ctx, objInfo, ILMTransition, tags, traceFn)
Ok(())
}
#[tracing::instrument(level = "debug", skip(self))]
async fn restore_transitioned_object(self: Arc<Self>, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
// Acquire write-lock early for the restore operation
// if !opts.no_lock {
// let guard_opt = self
// .namespace_lock
// .lock_guard(object, &self.locker_owner, Duration::from_secs(5), Duration::from_secs(10))
// .await?;
// if guard_opt.is_none() {
// return Err(Error::other("can not get lock. please retry".to_string()));
// }
// _lock_guard = guard_opt;
// }
let self_ = self.clone();
let set_restore_header_fn = async move |oi: &mut ObjectInfo, rerr: Option<Error>| -> Result<()> {
if rerr.is_none() {
return Ok(());
}
self.update_restore_metadata(bucket, object, oi, opts).await?;
Err(rerr.unwrap())
};
let mut oi = ObjectInfo::default();
let fi = self_.clone().get_object_fileinfo(bucket, object, opts, true).await;
if let Err(err) = fi {
return set_restore_header_fn(&mut oi, Some(to_object_err(err, vec![bucket, object]))).await;
}
let (actual_fi, _, _) = fi?;
oi = ObjectInfo::from_file_info(&actual_fi, bucket, object, opts.versioned || opts.version_suspended);
let ropts = put_restore_opts(bucket, object, &opts.transition.restore_request, &oi).await?;
if oi.parts.len() == 1 {
let mut opts = opts.clone();
opts.part_number = Some(1);
let rs: Option<HTTPRangeSpec> = None;
let gr = get_transitioned_object_reader(bucket, object, &rs, &HeaderMap::new(), &oi, &opts).await;
if let Err(err) = gr {
return set_restore_header_fn(&mut oi, Some(to_object_err(err.into(), vec![bucket, object]))).await;
}
let gr = gr?;
let reader = BufReader::new(gr.stream);
let hash_reader = HashReader::from_stream(reader, gr.object_info.size, gr.object_info.size, None, None, false)?;
let mut p_reader = PutObjReader::new(hash_reader);
return match self_.clone().put_object(bucket, object, &mut p_reader, &ropts).await {
Ok(restored_info) => {
send_event(EventArgs {
event_name: EventName::ObjectRestoreCompleted.as_str().to_string(),
bucket_name: bucket.to_string(),
object: restored_info,
user_agent: "Internal: [Restore-Completed]".to_string(),
host: runtime_sources::default_local_node_name(),
..Default::default()
});
Ok(())
}
Err(err) => set_restore_header_fn(&mut oi, Some(to_object_err(err, vec![bucket, object]))).await,
};
}
let res = self_.clone().new_multipart_upload(bucket, object, &ropts).await?;
//if err != nil {
// return set_restore_header_fn(&mut oi, err).await;
//}
let mut uploaded_parts: Vec<CompletePart> = vec![];
let parts = Arc::clone(&oi.parts);
let mut part_offset: i64 = 0;
for part_info in parts.iter() {
let mut part_opts = opts.clone();
part_opts.part_number = Some(part_info.number);
if part_info.actual_size <= 0 {
return set_restore_header_fn(
&mut oi,
Some(Error::other(format!("invalid multipart restore part size {}", part_info.actual_size))),
)
.await;
}
let part_end = match part_offset.checked_add(part_info.actual_size - 1) {
Some(end) => end,
None => {
return set_restore_header_fn(
&mut oi,
Some(Error::other("multipart restore part range overflow".to_string())),
)
.await;
}
};
let rs = Some(HTTPRangeSpec {
is_suffix_length: false,
start: part_offset,
end: part_end,
});
part_offset = match part_end.checked_add(1) {
Some(next) => next,
None => {
return set_restore_header_fn(
&mut oi,
Some(Error::other("multipart restore part offset overflow".to_string())),
)
.await;
}
};
let gr = match get_transitioned_object_reader(bucket, object, &rs, &HeaderMap::new(), &oi, &part_opts).await {
Ok(reader) => reader,
Err(err) => {
return set_restore_header_fn(&mut oi, Some(StorageError::Io(err))).await;
}
};
let reader = BufReader::new(gr.stream);
let hash_reader = HashReader::from_stream(reader, part_info.actual_size, part_info.actual_size, None, None, false)?;
let mut p_reader = PutObjReader::new(hash_reader);
let p_info = self_
.clone()
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &ObjectOptions::default())
.await?;
//if let Err(err) = p_info {
// return set_restore_header_fn(&mut oi, err).await;
//}
if p_info.size as i64 != part_info.actual_size {
return set_restore_header_fn(
&mut oi,
Some(Error::other(ObjectApiError::InvalidObjectState(GenericError {
bucket: bucket.to_string(),
object: object.to_string(),
..Default::default()
}))),
)
.await;
}
uploaded_parts.push(CompletePart {
part_num: p_info.part_num,
etag: p_info.etag,
checksum_crc32: None,
checksum_crc32c: None,
checksum_sha1: None,
checksum_sha256: None,
checksum_crc64nvme: None,
});
}
let restored_info = match self_
.clone()
.complete_multipart_upload(
bucket,
object,
&res.upload_id,
uploaded_parts,
&ObjectOptions {
mod_time: oi.mod_time,
..Default::default()
},
)
.await
{
Ok(info) => info,
Err(err) => return set_restore_header_fn(&mut oi, Some(err)).await,
};
send_event(EventArgs {
event_name: EventName::ObjectRestoreCompleted.as_str().to_string(),
bucket_name: bucket.to_string(),
object: restored_info,
user_agent: "Internal: [Restore-Completed]".to_string(),
host: runtime_sources::default_local_node_name(),
..Default::default()
});
Ok(())
}
#[tracing::instrument(level = "debug", skip(self))]
async fn put_object_tags(&self, bucket: &str, object: &str, tags: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
// Acquire write-lock for tag update (metadata write)
// if !opts.no_lock {
// let guard_opt = self
// .namespace_lock
// .lock_guard(object, &self.locker_owner, Duration::from_secs(5), Duration::from_secs(10))
// .await?;
// if guard_opt.is_none() {
// return Err(Error::other("can not get lock. please retry".to_string()));
// }
// _lock_guard = guard_opt;
// }
let (mut fi, _, disks) = self.get_object_fileinfo(bucket, object, opts, false).await?;
fi.metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags.to_owned());
// TODO: userdeefined
self.update_object_meta(bucket, object, fi.clone(), disks.as_slice()).await?;
// TODO: versioned
Ok(ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended))
}
#[tracing::instrument(skip(self))]
async fn delete_object_tags(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
self.put_object_tags(bucket, object, "", opts).await
}
#[tracing::instrument(skip(self))]
async fn verify_object_integrity(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
let get_object_reader = <Self as crate::storage_api_contracts::object::ObjectIO>::get_object_reader(
self,
bucket,
object,
None,
HeaderMap::new(),
opts,
)
.await?;
// Stream to sink to avoid loading entire object into memory during verification
let mut reader = get_object_reader.stream;
tokio::io::copy(&mut reader, &mut tokio::io::sink()).await?;
Ok(())
}
}
fn should_preserve_delete_replication_state(opts: &ObjectOptions) -> bool {
opts.delete_replication.as_ref().is_some_and(|state| {
state.replica_status == ReplicationStatusType::Replica
|| (!state.replicate_decision_str.is_empty()
&& (!state.composite_replication_status().is_empty() || !state.composite_version_purge_status().is_empty()))
}) || opts.version_purge_status() == VersionPurgeStatusType::Complete
}
fn should_force_delete_marker_for_missing_version(opts: &ObjectOptions) -> bool {
opts.delete_marker || (opts.versioned && opts.version_id.is_none() && !opts.data_movement)
}
fn resolve_delete_version_state(opts: &ObjectOptions, goi: &ObjectInfo, version_found: bool) -> (bool, bool) {
let mut mark_delete = goi.version_id.is_some() || (opts.versioned && opts.version_id.is_none());
let mut delete_marker = opts.versioned;
if opts.version_id.is_some() {
// Decommission/rebalance may recreate a delete marker on a new pool before that
// exact version exists there, so we must still treat it as a mark-delete write.
if opts.data_movement && opts.delete_marker && !version_found {
mark_delete = true;
}
let delete_marker_version_purge = version_found && goi.delete_marker && !opts.version_purge_status().is_empty();
if version_found && opts.delete_marker_replication_status() == ReplicationStatusType::Replica {
mark_delete = false;
}
if opts.version_purge_status().is_empty() && opts.delete_marker_replication_status().is_empty() {
mark_delete = false;
}
if opts.version_purge_status() == VersionPurgeStatusType::Complete {
mark_delete = false;
}
let replica_delete_marker_version_purge =
version_found && goi.delete_marker && opts.delete_marker_replication_status() == ReplicationStatusType::Replica;
if delete_marker_version_purge {
mark_delete = false;
}
if !version_found && !opts.delete_marker && opts.delete_marker_replication_status() == ReplicationStatusType::Replica {
delete_marker = false;
}
if version_found
&& (!goi.version_purge_status.is_empty()
|| !goi.delete_marker
|| replica_delete_marker_version_purge
|| delete_marker_version_purge)
{
delete_marker = false;
}
}
(mark_delete, delete_marker)
}
impl SetDisks {
#[tracing::instrument(skip(self, fi, opts))]
pub(crate) async fn decommission_tiered_object(
&self,
bucket: &str,
object: &str,
fi: &FileInfo,
opts: &ObjectOptions,
) -> Result<()> {
let _lock_guard = if !opts.no_lock {
Some(
self.new_ns_lock(bucket, object)
.await?
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(|e| self.map_namespace_lock_error(bucket, object, "write", e))?,
)
} else {
None
};
let disks = self.disks.read().await.clone();
let storage_class = opts.user_defined.get(AMZ_STORAGE_CLASS).map(String::as_str);
let (fi, write_quorum) =
build_tiered_decommission_file_info(bucket, object, fi, disks.len(), self.default_parity_count, storage_class);
let parts_metadata = vec![fi.clone(); disks.len()];
let (shuffle_disks, parts_metadata) = Self::shuffle_disks_and_parts_metadata(&disks, &parts_metadata, &fi);
let mut errs = Vec::with_capacity(shuffle_disks.len());
let mut futures = Vec::with_capacity(shuffle_disks.len());
for (index, disk) in shuffle_disks.iter().enumerate() {
let mut file_info = parts_metadata[index].clone();
file_info.erasure.index = index + 1;
futures.push(async move {
if let Some(disk) = disk {
disk.write_metadata("", bucket, object, file_info).await
} else {
Err(DiskError::DiskNotFound)
}
});
}
for result in join_all(futures).await {
match result {
Ok(_) => errs.push(None),
Err(err) => errs.push(Some(err)),
}
}
resolve_tiered_decommission_write_quorum_result(&errs, write_quorum, bucket, object)
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::list::ListOperations for SetDisks {
type Error = Error;
type ListObjectsV2Info = ListObjectsV2Info;
type ListObjectVersionsInfo = ListObjectVersionsInfo;
type ObjectInfoOrErr = ObjectInfoOrErr;
type WalkOptions = WalkOptions;
type WalkCancellation = CancellationToken;
type WalkResultSender = Sender<ObjectInfoOrErr>;
#[tracing::instrument(skip(self))]
async fn list_objects_v2(
self: Arc<Self>,
bucket: &str,
prefix: &str,
continuation_token: Option<String>,
delimiter: Option<String>,
max_keys: i32,
fetch_owner: bool,
start_after: Option<String>,
incl_deleted: bool,
) -> Result<ListObjectsV2Info> {
self.inner_list_objects_v2(
bucket,
prefix,
continuation_token,
delimiter,
max_keys,
fetch_owner,
start_after,
incl_deleted,
)
.await
}
#[tracing::instrument(skip(self))]
async fn list_object_versions(
self: Arc<Self>,
bucket: &str,
prefix: &str,
marker: Option<String>,
version_marker: Option<String>,
delimiter: Option<String>,
max_keys: i32,
) -> Result<ListObjectVersionsInfo> {
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
.await
}
async fn walk(
self: Arc<Self>,
rx: CancellationToken,
bucket: &str,
prefix: &str,
result: Sender<ObjectInfoOrErr>,
opts: WalkOptions,
) -> Result<()> {
self.walk_internal(rx, bucket, prefix, result, opts).await
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
type Error = Error;
type ObjectInfo = ObjectInfo;
type ObjectOptions = ObjectOptions;
type PutObjectReader = PutObjReader;
type CompletePart = CompletePart;
type ListMultipartsInfo = ListMultipartsInfo;
type MultipartUploadResult = MultipartUploadResult;
type PartInfo = PartInfo;
type MultipartInfo = MultipartInfo;
type ListPartsInfo = ListPartsInfo;
#[tracing::instrument(skip(self))]
async fn copy_object_part(
&self,
_src_bucket: &str,
_src_object: &str,
_dst_bucket: &str,
_dst_object: &str,
_upload_id: &str,
_part_id: usize,
_start_offset: i64,
_length: i64,
_src_info: &ObjectInfo,
_src_opts: &ObjectOptions,
_dst_opts: &ObjectOptions,
) -> Result<()> {
Err(StorageError::NotImplemented)
}
#[tracing::instrument(level = "debug", skip(self, data, opts))]
async fn put_object_part(
&self,
bucket: &str,
object: &str,
upload_id: &str,
part_id: usize,
data: &mut PutObjReader,
opts: &ObjectOptions,
) -> Result<PartInfo> {
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
let (fi, _) = self.check_upload_id_exists(bucket, object, upload_id, true).await?;
let write_quorum = fi.write_quorum(self.default_write_quorum());
if let Some(checksum) = fi.metadata.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM)
&& !checksum.is_empty()
&& data
.as_hash_reader()
.content_crc_type()
.is_none_or(|v| v.to_string() != *checksum)
{
return Err(Error::other(format!("checksum mismatch: {checksum}")));
}
let disks = self.get_disks_internal().await;
// let (disks, filtered_online) = self.filter_online_disks(disks_snapshot).await;
// if filtered_online < write_quorum {
// warn!(
// "online disk snapshot {} below write quorum {} for multipart {}/{}; returning erasure write quorum error",
// filtered_online, write_quorum, bucket, object
// );
// return Err(to_object_err(Error::ErasureWriteQuorum, vec![bucket, object]));
// }
let shuffle_disks = Self::shuffle_disks(&disks, &fi.erasure.distribution);
let part_suffix = format!("part.{part_id}");
let tmp_part = format!("{}x{}", Uuid::new_v4(), OffsetDateTime::now_utc().unix_timestamp());
let tmp_part_path = Arc::new(format!("{tmp_part}/{part_suffix}"));
let erasure = coding::Erasure::new(fi.erasure.data_blocks, fi.erasure.parity_blocks, fi.erasure.block_size);
let writer_setup_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
let mut writers = Vec::with_capacity(shuffle_disks.len());
let mut errors = Vec::with_capacity(shuffle_disks.len());
for disk_op in shuffle_disks.iter() {
if let Some(disk) = disk_op {
let writer = match create_bitrot_writer(
false,
Some(disk),
RUSTFS_META_TMP_BUCKET,
&tmp_part_path,
erasure.shard_file_size(data.size()),
erasure.shard_size(),
HashAlgorithm::HighwayHash256S,
)
.await
{
Ok(writer) => writer,
Err(err) => {
warn!(
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
disk = ?disk,
state = "bitrot_writer_skipped",
error = ?err,
"Set disk multipart bitrot writer skipped"
);
errors.push(Some(err));
writers.push(None);
continue;
}
};
writers.push(Some(writer));
errors.push(None);
} else {
errors.push(Some(DiskError::DiskNotFound));
writers.push(None);
}
}
if let Some(stage_start) = writer_setup_stage_start {
rustfs_io_metrics::record_put_object_stage_duration(
"multipart_set_disk_writer_setup",
stage_start.elapsed().as_secs_f64() * 1000.0,
);
}
let nil_count = errors.iter().filter(|&e| e.is_none()).count();
if nil_count < write_quorum {
if let Some(write_err) = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, write_quorum) {
log_multipart_write_quorum_failure(
MultipartWriteQuorumContext {
stage: MULTIPART_WRITE_QUORUM_WRITER_SETUP,
bucket,
object,
upload_id,
part_number: Some(part_id),
},
&errors,
write_quorum,
&write_err,
);
return Err(to_object_err(write_err.into(), vec![bucket, object]));
}
return Err(Error::other(format!("not enough disks to write: {errors:?}")));
}
// Capture the original part size before swapping the stream out for encoding.
let multipart_part_size = data.size();
let stream = mem::replace(
&mut data.stream,
HashReader::from_stream(Cursor::new(Vec::new()), 0, 0, None, None, false)?,
);
let write_path = classify_multipart_part_write_path(multipart_part_size, fi.erasure.block_size);
rustfs_io_metrics::record_put_object_path(write_path.multipart_metric_label());
let encode_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
let (reader, w_size) = match write_path {
SmallWritePath::SingleBlockNonInline => {
Arc::new(erasure)
.encode_single_block_non_inline(stream, &mut writers, write_quorum)
.await?
}
SmallWritePath::PipelineBatchedLarge => Arc::new(erasure).encode_batched(stream, &mut writers, write_quorum).await?,
SmallWritePath::Inline | SmallWritePath::Pipeline => {
Arc::new(erasure).encode(stream, &mut writers, write_quorum).await?
}
}; // TODO: delete temporary directory on error
if let Some(stage_start) = encode_stage_start {
rustfs_io_metrics::record_put_object_stage_duration(
"multipart_set_disk_encode",
stage_start.elapsed().as_secs_f64() * 1000.0,
);
}
let _ = mem::replace(&mut data.stream, reader);
if (w_size as i64) < data.size() {
warn!(
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket,
object,
part_number = part_id,
written_size = w_size,
expected_size = data.size(),
state = "short_write",
"Set disk multipart write produced fewer bytes than expected"
);
return Err(Error::other(format!(
"put_object_part write size < data.size(), w_size={}, data.size={}",
w_size,
data.size()
)));
}
let index_op = data
.stream
.try_get_index()
.map(crate::io_support::rio::compression_index_storage_bytes);
let mut etag = data.stream.try_resolve_etag().unwrap_or_default();
if let Some(ref tag) = opts.preserve_etag {
etag = tag.clone();
}
let mut actual_size = data.actual_size();
if actual_size < 0 {
let is_compressed = fi.is_compressed();
if !is_compressed {
actual_size = w_size as i64;
}
}
if fi.is_compressed() {
record_compression_total_memory(actual_size as u64, w_size as u64).await;
}
let checksums = data.as_hash_reader().content_crc();
let part_info = ObjectPartInfo {
etag: etag.clone(),
number: part_id,
size: w_size,
mod_time: Some(OffsetDateTime::now_utc()),
actual_size,
index: index_op,
checksums: if checksums.is_empty() { None } else { Some(checksums) },
..Default::default()
};
let part_info_buff = part_info.marshal_msg()?;
drop(writers); // drop writers to close all files
let part_path = format!("{}/{}/{}", upload_id_path, fi.data_dir.unwrap_or_default(), part_suffix);
let _ = self
.rename_part(
&disks,
RUSTFS_META_TMP_BUCKET,
&tmp_part_path,
RUSTFS_META_MULTIPART_BUCKET,
&part_path,
part_info_buff.into(),
write_quorum,
Some(MultipartWriteQuorumContext {
stage: MULTIPART_WRITE_QUORUM_RENAME_PART,
bucket,
object,
upload_id,
part_number: Some(part_id),
}),
)
.await?;
let ret: PartInfo = PartInfo {
etag: Some(etag.clone()),
part_num: part_id,
last_mod: Some(OffsetDateTime::now_utc()),
size: w_size,
actual_size,
};
// error!("put_object_part ret {:?}", &ret);
Ok(ret)
}
#[tracing::instrument(skip(self))]
async fn list_object_parts(
&self,
bucket: &str,
object: &str,
upload_id: &str,
part_number_marker: Option<usize>,
mut max_parts: usize,
opts: &ObjectOptions,
) -> Result<ListPartsInfo> {
let (fi, _) = self.check_upload_id_exists(bucket, object, upload_id, false).await?;
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
if max_parts > MAX_PARTS_COUNT {
max_parts = MAX_PARTS_COUNT;
}
let part_number_marker = part_number_marker.unwrap_or_default();
// Extract storage class from metadata, default to STANDARD if not found
let storage_class = fi
.metadata
.get(AMZ_STORAGE_CLASS)
.cloned()
.unwrap_or_else(|| storageclass::STANDARD.to_string());
let mut ret = ListPartsInfo {
bucket: bucket.to_owned(),
object: object.to_owned(),
upload_id: upload_id.to_owned(),
storage_class,
max_parts,
part_number_marker,
user_defined: {
let mut metadata = fi.metadata.clone();
strip_internal_multipart_metadata(&mut metadata);
metadata
},
..Default::default()
};
if max_parts == 0 {
return Ok(ret);
}
let online_disks = self.get_disks_internal().await;
let read_quorum = fi.read_quorum(self.default_read_quorum());
let part_path = format!(
"{}{}",
path_join_buf(&[
&upload_id_path,
fi.data_dir.map(|v| v.to_string()).unwrap_or_default().as_str(),
]),
SLASH_SEPARATOR
);
let mut part_numbers = match Self::list_parts(&online_disks, &part_path, read_quorum).await {
Ok(parts) => parts,
Err(err) => {
if err == DiskError::FileNotFound {
return Ok(ret);
}
return Err(to_object_err(err.into(), vec![bucket, object]));
}
};
if part_numbers.is_empty() {
return Ok(ret);
}
let Some(remaining_part_numbers) = parts_after_marker(&part_numbers, part_number_marker) else {
return Ok(ret);
};
part_numbers = remaining_part_numbers.to_vec();
let mut parts = Vec::with_capacity(part_numbers.len());
let part_meta_paths = part_numbers
.iter()
.map(|v| format!("{part_path}part.{v}.meta"))
.collect::<Vec<String>>();
let object_parts =
Self::read_parts(&online_disks, RUSTFS_META_MULTIPART_BUCKET, &part_meta_paths, &part_numbers, read_quorum)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object, upload_id]))?;
let mut count = max_parts;
for (i, part) in object_parts.iter().enumerate() {
if let Some(err) = &part.error {
warn!("list_object_parts part error: {:?}", &err);
}
parts.push(PartInfo {
etag: Some(part.etag.clone()),
part_num: part.number,
last_mod: part.mod_time,
size: part.size,
actual_size: part.actual_size,
});
count -= 1;
if count == 0 {
break;
}
}
ret.parts = parts;
if object_parts.len() > ret.parts.len() {
ret.is_truncated = true;
ret.next_part_number_marker = ret.parts.last().map(|v| v.part_num).unwrap_or_default();
}
Ok(ret)
}
#[tracing::instrument(skip(self))]
async fn list_multipart_uploads(
&self,
bucket: &str,
object: &str,
key_marker: Option<String>,
upload_id_marker: Option<String>,
delimiter: Option<String>,
max_uploads: usize,
) -> Result<ListMultipartsInfo> {
let disks = {
let disks = self.get_online_local_disks().await;
if disks.is_empty() {
// TODO: getOnlineDisksWithHealing
self.get_online_disks().await
} else {
disks
}
};
let mut upload_ids: Vec<String> = Vec::new();
for disk in disks.iter().flatten() {
if !disk.is_online().await {
continue;
}
let has_uoload_ids = match disk
.list_dir(
bucket,
RUSTFS_META_MULTIPART_BUCKET,
Self::get_multipart_sha_dir(bucket, object).as_str(),
-1,
)
.await
{
Ok(res) => Some(res),
Err(err) => {
if err == DiskError::DiskNotFound {
None
} else if err == DiskError::FileNotFound {
return Ok(ListMultipartsInfo {
key_marker: key_marker.to_owned(),
max_uploads,
prefix: object.to_owned(),
delimiter: delimiter.to_owned(),
..Default::default()
});
} else {
return Err(to_object_err(err.into(), vec![bucket, object]));
}
}
};
if let Some(ids) = has_uoload_ids {
upload_ids = ids;
break;
}
}
let mut uploads = Vec::new();
let mut populated_upload_ids = HashSet::new();
for upload_id in upload_ids.iter() {
let upload_id = upload_id.trim_end_matches(SLASH_SEPARATOR).to_string();
if populated_upload_ids.contains(&upload_id) {
continue;
}
let start_time = {
let now = OffsetDateTime::now_utc();
let splits: Vec<&str> = upload_id.split("x").collect();
if splits.len() == 2 {
if let Ok(unix) = splits[1].parse::<i128>() {
OffsetDateTime::from_unix_timestamp_nanos(unix)?
} else {
now
}
} else {
now
}
};
uploads.push(MultipartInfo {
bucket: bucket.to_owned(),
object: object.to_owned(),
upload_id: runtime_sources::deployment_upload_id(&upload_id),
initiated: Some(start_time),
..Default::default()
});
populated_upload_ids.insert(upload_id);
}
uploads.sort_by_key(|a| a.initiated);
let mut upload_idx = 0;
if let Some(upload_id_marker) = &upload_id_marker {
while upload_idx < uploads.len() {
if &uploads[upload_idx].upload_id != upload_id_marker {
upload_idx += 1;
continue;
}
if &uploads[upload_idx].upload_id == upload_id_marker {
upload_idx += 1;
break;
}
upload_idx += 1;
}
}
let mut ret_uploads = Vec::new();
let mut next_upload_id_marker = None;
while upload_idx < uploads.len() {
ret_uploads.push(uploads[upload_idx].clone());
next_upload_id_marker = Some(uploads[upload_idx].upload_id.clone());
upload_idx += 1;
if ret_uploads.len() > max_uploads {
break;
}
}
let is_truncated = ret_uploads.len() < uploads.len();
if !is_truncated {
next_upload_id_marker = None;
}
Ok(ListMultipartsInfo {
key_marker: key_marker.to_owned(),
next_upload_id_marker,
max_uploads,
is_truncated,
uploads: ret_uploads,
prefix: object.to_owned(),
delimiter: delimiter.to_owned(),
..Default::default()
})
}
#[tracing::instrument(skip(self))]
async fn new_multipart_upload(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<MultipartUploadResult> {
let mut _object_lock_guard = None;
if opts.http_preconditions.is_some() {
if !opts.no_lock {
_object_lock_guard = Some(
self.acquire_write_lock_diag("new_multipart_upload_precondition", bucket, object)
.await?,
);
}
if let Some(err) = self.check_write_precondition(bucket, object, opts).await {
return Err(err);
}
}
let disks = self.disks.read().await;
let disks = disks.clone();
let mut user_defined = opts.user_defined.clone();
if let Some(ref etag) = opts.preserve_etag {
user_defined.insert("etag".to_owned(), etag.clone());
}
if let Some(sc) = user_defined.get(AMZ_STORAGE_CLASS)
&& sc == storageclass::STANDARD
{
let _ = user_defined.remove(AMZ_STORAGE_CLASS);
}
let sc_parity_drives = runtime_sources::storage_class_parity(user_defined.get(AMZ_STORAGE_CLASS).map(String::as_str));
let mut parity_drives = sc_parity_drives.unwrap_or(self.default_parity_count);
if opts.max_parity {
parity_drives = disks.len() / 2;
}
let data_drives = disks.len() - parity_drives;
let mut write_quorum = data_drives;
if data_drives == parity_drives {
write_quorum += 1
}
let mut fi = FileInfo::new([bucket, object].join("/").as_str(), data_drives, parity_drives);
fi.version_id = if let Some(vid) = &opts.version_id {
Some(Uuid::parse_str(vid)?)
} else {
None
};
if opts.versioned && opts.version_id.is_none() {
fi.version_id = Some(Uuid::new_v4());
}
fi.data_dir = Some(Uuid::new_v4());
if let Some(cssum) = get_header_map(&user_defined, SUFFIX_REPLICATION_SSEC_CRC)
&& !cssum.is_empty()
{
fi.checksum = base64_simd::STANDARD.decode_to_vec(&cssum).ok().map(Bytes::from);
remove_header_map(&mut user_defined, SUFFIX_REPLICATION_SSEC_CRC);
}
let parts_metadata = vec![fi.clone(); disks.len()];
if !user_defined.contains_key("content-type") {
// TODO: get content-type
}
if let Some(sc) = user_defined.get(AMZ_STORAGE_CLASS)
&& sc == storageclass::STANDARD
{
let _ = user_defined.remove(AMZ_STORAGE_CLASS);
}
if let Some(checksum) = &opts.want_checksum {
user_defined.insert(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM.to_string(), checksum.checksum_type.to_string());
user_defined.insert(
rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE.to_string(),
checksum.checksum_type.obj_type().to_string(),
);
}
user_defined.insert(RUSTFS_MULTIPART_BUCKET_KEY.to_string(), bucket.to_string());
user_defined.insert(RUSTFS_MULTIPART_OBJECT_KEY.to_string(), object.to_string());
let (shuffle_disks, mut parts_metadatas) = Self::shuffle_disks_and_parts_metadata(&disks, &parts_metadata, &fi);
let mod_time = opts.mod_time.unwrap_or_else(OffsetDateTime::now_utc);
for f in parts_metadatas.iter_mut() {
f.metadata = user_defined.clone();
f.mod_time = Some(mod_time);
f.fresh = true;
}
// fi.mod_time = Some(now);
let upload_uuid = format!("{}x{}", Uuid::new_v4(), mod_time.unix_timestamp_nanos());
let upload_id = runtime_sources::deployment_upload_id(&upload_uuid);
let upload_path = Self::get_upload_id_dir(bucket, object, upload_uuid.as_str());
Self::write_unique_file_info(
&shuffle_disks,
bucket,
RUSTFS_META_MULTIPART_BUCKET,
upload_path.as_str(),
&parts_metadatas,
write_quorum,
)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
// evalDisks
Ok(MultipartUploadResult {
upload_id,
checksum_algo: user_defined.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM).cloned(),
checksum_type: user_defined.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE).cloned(),
})
}
#[tracing::instrument(skip(self))]
async fn get_multipart_info(
&self,
bucket: &str,
object: &str,
upload_id: &str,
_opts: &ObjectOptions,
) -> Result<MultipartInfo> {
// TODO: nslock
let (mut fi, _) = self
.check_upload_id_exists(bucket, object, upload_id, false)
.await
.map_err(|e| to_object_err(e, vec![bucket, object, upload_id]))?;
Ok(MultipartInfo {
bucket: bucket.to_owned(),
object: object.to_owned(),
upload_id: upload_id.to_owned(),
user_defined: {
strip_internal_multipart_metadata(&mut fi.metadata);
fi.metadata.clone()
},
..Default::default()
})
}
#[tracing::instrument(skip(self))]
async fn abort_multipart_upload(&self, bucket: &str, object: &str, upload_id: &str, _opts: &ObjectOptions) -> Result<()> {
self.check_upload_id_exists(bucket, object, upload_id, false).await?;
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
self.delete_all(RUSTFS_META_MULTIPART_BUCKET, &upload_id_path).await
}
// complete_multipart_upload finished
#[tracing::instrument(skip(self))]
async fn complete_multipart_upload(
self: Arc<Self>,
bucket: &str,
object: &str,
upload_id: &str,
uploaded_parts: Vec<CompletePart>,
opts: &ObjectOptions,
) -> Result<ObjectInfo> {
self.invalidate_get_object_metadata_cache(bucket, object).await;
let mut object_lock_guard = None;
if opts.http_preconditions.is_some() {
if !opts.no_lock {
object_lock_guard = Some(
self.acquire_write_lock_diag("complete_multipart_upload_precondition", bucket, object)
.await?,
);
}
if let Some(err) = self.check_write_precondition(bucket, object, opts).await {
return Err(err);
}
}
let (mut fi, files_metas) = self.check_upload_id_exists(bucket, object, upload_id, true).await?;
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
let write_quorum = fi.write_quorum(self.default_write_quorum());
let read_quorum = fi.read_quorum(self.default_read_quorum());
let disks = self.disks.read().await;
let disks = disks.clone();
// let disks = Self::shuffle_disks(&disks, &fi.erasure.distribution);
let part_path = format!("{}/{}/", upload_id_path, fi.data_dir.unwrap_or(Uuid::nil()));
let part_meta_paths = uploaded_parts
.iter()
.map(|v| format!("{part_path}part.{0}.meta", v.part_num))
.collect::<Vec<String>>();
let part_numbers = uploaded_parts.iter().map(|v| v.part_num).collect::<Vec<usize>>();
let object_parts = Self::read_parts(&disks, RUSTFS_META_MULTIPART_BUCKET, &part_meta_paths, &part_numbers, read_quorum)
.await
.map_err(|err| to_object_err(err.into(), vec![bucket, object]))?;
if object_parts.len() != uploaded_parts.len() {
return Err(Error::other("part result number err"));
}
let mut checksum_type = rustfs_rio::ChecksumType::NONE;
if let Some(cs) = fi.metadata.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM) {
let Some(ct) = fi.metadata.get(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE) else {
return Err(Error::other("checksum type not found"));
};
checksum_type = rustfs_rio::ChecksumType::from_string_with_obj_type(cs, ct);
if let Some(want) = opts.want_checksum.as_ref()
&& !want.checksum_type.is(checksum_type)
{
return Err(Error::other(format!("checksum type mismatch, got {:?}, want {:?}", want, checksum_type)));
}
}
for (i, part) in object_parts.iter().enumerate() {
if let Some(err) = &part.error {
let mapped_err = complete_multipart_part_error(uploaded_parts[i].part_num, err, bucket, object);
let result = complete_multipart_part_error_result(&mapped_err);
if matches!(mapped_err, Error::InvalidPart(_, _, _)) {
debug!(
target: "rustfs_ecstore::set_disk",
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
op = "complete_multipart_upload",
result = result,
bucket = %bucket,
object = %object,
upload_id = %upload_id,
uploaded_part_num = uploaded_parts[i].part_num,
observed_part_num = part.number,
read_quorum = read_quorum,
write_quorum = write_quorum,
error = %err,
"Set disk multipart part missing"
);
} else {
warn!(
target: "rustfs_ecstore::set_disk",
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
op = "complete_multipart_upload",
result = result,
bucket = %bucket,
object = %object,
upload_id = %upload_id,
uploaded_part_num = uploaded_parts[i].part_num,
observed_part_num = part.number,
read_quorum = read_quorum,
write_quorum = write_quorum,
error = %err,
"Set disk multipart part resolution failed"
);
}
return Err(mapped_err);
}
if uploaded_parts[i].part_num != part.number {
error!(
"complete_multipart_upload part_id err part_id != part_num {} != {}",
uploaded_parts[i].part_num, part.number
);
return Err(Error::InvalidPart(uploaded_parts[i].part_num, bucket.to_owned(), object.to_owned()));
}
fi.add_object_part(
part.number,
part.etag.clone(),
part.size,
part.mod_time,
part.actual_size,
part.index.clone(),
part.checksums.clone(),
);
}
let (shuffle_disks, mut parts_metadatas) = Self::shuffle_disks_and_parts_metadata_by_index(&disks, &files_metas, &fi);
let curr_fi = fi.clone();
fi.parts = Vec::with_capacity(uploaded_parts.len());
let mut object_size: usize = 0;
let mut object_actual_size: i64 = 0;
let mut checksum_combined = bytes::BytesMut::new();
let mut checksum = rustfs_rio::Checksum {
checksum_type,
..Default::default()
};
// Build a lookup map for O(1) part resolution instead of O(n) find() in the loop
// This optimizes from O(n^2) to O(n) when processing many parts
use std::collections::HashMap;
let part_lookup: HashMap<usize, &ObjectPartInfo> = curr_fi.parts.iter().map(|part| (part.number, part)).collect();
for (i, p) in uploaded_parts.iter().enumerate() {
let Some(ext_part) = part_lookup.get(&p.part_num) else {
error!(
"complete_multipart_upload part not found: part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, "".to_owned(), p.etag.clone().unwrap_or_default()));
};
debug!(
target:"rustfs_ecstore::set_disk",
event = EVENT_SET_DISK_MULTIPART,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
part_number = p.part_num,
part_size = ext_part.size,
part_actual_size = ext_part.actual_size,
state = "part_validated",
"Set disk multipart part validated"
);
// Normalize ETags by removing quotes before comparison (PR #592 compatibility)
let client_etag = p.etag.as_ref().map(|e| rustfs_utils::path::trim_etag(e));
let stored_etag = Some(rustfs_utils::path::trim_etag(&ext_part.etag));
if client_etag != stored_etag {
error!(
"complete_multipart_upload etag err client={:?}, stored={:?}, part_id={}, bucket={}, object={}",
p.etag, ext_part.etag, p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
}
// TODO: crypto
if (i < uploaded_parts.len() - 1) && !is_min_allowed_part_size(ext_part.actual_size) {
error!(
"complete_multipart_upload part size too small: part {} size {} is less than minimum {}",
p.part_num,
ext_part.actual_size,
GLOBAL_MIN_PART_SIZE.as_u64()
);
return Err(Error::EntityTooSmall(
p.part_num,
ext_part.actual_size,
GLOBAL_MIN_PART_SIZE.as_u64() as i64,
));
}
if checksum_type.is_set() {
let Some(crc) = ext_part
.checksums
.as_ref()
.and_then(|f| f.get(checksum_type.to_string().as_str()))
.cloned()
else {
error!(
"complete_multipart_upload fi.checksum not found type={checksum_type}, part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
};
let Some(part_crc) = complete_part_checksum(p, checksum_type) else {
error!(
"complete_multipart_upload checksum type={checksum_type}, part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
};
if let Some(part_crc) = part_crc
&& part_crc != crc
{
error!("complete_multipart_upload checksum_type={checksum_type:?}, part_crc={part_crc:?}, crc={crc:?}");
error!(
"complete_multipart_upload checksum mismatch part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
}
let Some(cs) = rustfs_rio::Checksum::new_with_type(checksum_type, &crc) else {
error!(
"complete_multipart_upload checksum new_with_type failed part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
};
if !cs.valid() {
error!(
"complete_multipart_upload checksum valid failed part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
}
if checksum_type.full_object_requested()
&& let Err(err) = checksum.add_part(&cs, ext_part.actual_size)
{
error!(
"complete_multipart_upload checksum add_part failed part_id={}, bucket={}, object={}",
p.part_num, bucket, object
);
return Err(Error::InvalidPart(p.part_num, ext_part.etag.clone(), p.etag.clone().unwrap_or_default()));
}
checksum_combined.extend_from_slice(cs.raw.as_slice());
}
// TODO: check min part size
object_size += ext_part.size;
object_actual_size += ext_part.actual_size;
fi.parts.push(completed_multipart_object_part(p.part_num, ext_part));
}
if let Some(wtcs) = opts.want_checksum.as_ref() {
if checksum_type.full_object_requested() {
if wtcs.encoded != checksum.encoded {
error!(
"complete_multipart_upload checksum mismatch want={}, got={}",
wtcs.encoded, checksum.encoded
);
return Err(Error::other(format!(
"complete_multipart_upload checksum mismatch want={}, got={}",
wtcs.encoded, checksum.encoded
)));
}
} else if let Err(err) = wtcs.matches(&checksum_combined, uploaded_parts.len() as i32) {
error!(
"complete_multipart_upload checksum matches failed want={}, got={}",
wtcs.encoded, checksum.encoded
);
return Err(Error::other(format!(
"complete_multipart_upload checksum matches failed want={}, got={}",
wtcs.encoded, checksum.encoded
)));
}
}
if let Some(rc_crc) = get_header_map(&opts.user_defined, SUFFIX_REPLICATION_SSEC_CRC) {
if let Ok(rc_crc_bytes) = base64_simd::STANDARD.decode_to_vec(&rc_crc) {
fi.checksum = Some(Bytes::from(rc_crc_bytes));
} else {
error!("complete_multipart_upload decode rc_crc failed rc_crc={}", rc_crc);
}
}
if checksum_type.is_set() {
checksum_type
.merge(rustfs_rio::ChecksumType::MULTIPART)
.merge(rustfs_rio::ChecksumType::INCLUDES_MULTIPART);
if !checksum_type.full_object_requested() {
checksum = rustfs_rio::Checksum::new_from_data(checksum_type, &checksum_combined)
.ok_or_else(|| Error::other("checksum new_from_data failed"))?;
}
fi.checksum = Some(checksum.to_bytes(&checksum_combined));
}
fi.metadata.remove(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM);
fi.metadata.remove(rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE);
strip_internal_multipart_metadata(&mut fi.metadata);
fi.size = object_size as i64;
fi.mod_time = opts.mod_time;
if fi.mod_time.is_none() {
fi.mod_time = Some(OffsetDateTime::now_utc());
}
// etag
let etag = {
if let Some(etag) = opts.user_defined.get("etag") {
etag.clone()
} else {
get_complete_multipart_md5(&uploaded_parts)
}
};
fi.metadata.insert("etag".to_owned(), etag);
let persist_encryption_original_size = should_persist_encryption_original_size(&fi.metadata);
if opts.replication_request {
if let Some(actual_size) = get_str(&opts.user_defined, SUFFIX_ACTUAL_OBJECT_SIZE_CAP) {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.clone());
if persist_encryption_original_size {
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), actual_size);
}
}
} else {
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, object_actual_size.to_string());
if persist_encryption_original_size {
fi.metadata
.insert("x-rustfs-encryption-original-size".to_string(), object_actual_size.to_string());
}
}
if fi.is_compressed() {
insert_str(&mut fi.metadata, SUFFIX_COMPRESSION_SIZE, object_size.to_string());
}
if opts.data_movement {
fi.set_data_moved();
}
for meta in parts_metadatas.iter_mut() {
if meta.is_valid() {
meta.size = fi.size;
meta.mod_time = fi.mod_time;
meta.parts.clone_from(&fi.parts);
meta.metadata = fi.metadata.clone();
meta.versioned = opts.versioned || opts.version_suspended;
meta.checksum = fi.checksum.clone();
}
}
let mut parts = Vec::with_capacity(curr_fi.parts.len());
for p in curr_fi.parts.iter() {
parts.push(path_join_buf(&[
&upload_id_path,
curr_fi.data_dir.unwrap_or(Uuid::nil()).to_string().as_str(),
format!("part.{}.meta", p.number).as_str(),
]));
if !fi.parts.iter().any(|v| v.number == p.number) {
parts.push(path_join_buf(&[
&upload_id_path,
curr_fi.data_dir.unwrap_or(Uuid::nil()).to_string().as_str(),
format!("part.{}", p.number).as_str(),
]));
}
}
if !opts.no_lock && object_lock_guard.is_none() {
object_lock_guard = Some(
self.acquire_write_lock_diag("complete_multipart_upload_commit", bucket, object)
.await?,
);
}
let complete_tail_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
self.cleanup_multipart_path(&parts).await;
let (online_disks, versions, op_old_dir, cleanup_disks) = Self::rename_data(
&shuffle_disks,
RUSTFS_META_MULTIPART_BUCKET,
&upload_id_path,
&parts_metadatas,
bucket,
object,
write_quorum,
)
.await?;
if let Some(old_dir) = op_old_dir {
self.commit_rename_data_dir(&cleanup_disks, bucket, object, &old_dir.to_string(), write_quorum)
.await?;
}
if let Some(stage_start) = complete_tail_stage_start {
rustfs_io_metrics::record_put_object_stage_duration(
"multipart_complete_tail",
stage_start.elapsed().as_secs_f64() * 1000.0,
);
}
drop(object_lock_guard); // drop object lock guard to release the lock
if let Some(versions) = versions {
let _ =
rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options(
bucket.to_string(),
Some(object.to_string()),
false,
Some(HealChannelPriority::Normal),
Some(self.pool_index),
Some(self.set_index),
))
.await;
}
let upload_id_path = upload_id_path.clone();
let store = self.clone();
let _cleanup_handle = tokio::spawn(async move {
let _ = store.delete_all(RUSTFS_META_MULTIPART_BUCKET, &upload_id_path).await;
});
for (i, op_disk) in online_disks.iter().enumerate() {
if let Some(disk) = op_disk
&& disk.is_online().await
{
fi = parts_metadatas[i].clone();
break;
}
}
record_capacity_scope_if_needed(opts.capacity_scope_token, &online_disks);
fi.is_latest = true;
self.invalidate_get_object_metadata_cache(bucket, object).await;
Ok(ObjectInfo::from_file_info(&fi, bucket, object, opts.versioned || opts.version_suspended))
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
type Error = Error;
type HealResultItem = HealResultItem;
type HealOptions = HealOpts;
#[tracing::instrument(skip(self))]
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
let disks = self.disks.read().await.clone();
let (formats, errs) = load_format_erasure_all(&disks, true).await;
let ref_format = match get_format_erasure_in_quorum(&formats) {
Ok(format) => format,
Err(err) => {
let can_use_cached_layout = count_errs(&errs, &DiskError::UnformattedDisk) > 0
&& formats.iter().flatten().all(|format| self.format.check_other(format).is_ok())
&& errs
.iter()
.all(|err| err.is_none() || matches!(err, Some(DiskError::UnformattedDisk)));
if can_use_cached_layout {
self.format.clone()
} else {
return Ok((HealResultItem::default(), Some(err)));
}
}
};
let endpoints = crate::layout::endpoints::Endpoints::from(self.set_endpoints.clone());
let before_drives = crate::layout::set_heal::formats_to_drives_info(&endpoints, &formats, &errs);
let mut result = HealResultItem {
heal_item_type: HealItemType::Metadata.to_string(),
detail: "disk-format".to_string(),
disk_count: self.set_drive_count,
set_count: 1,
before: Infos {
drives: before_drives.clone(),
},
after: Infos { drives: before_drives },
..Default::default()
};
if count_errs(&errs, &DiskError::UnformattedDisk) == 0 {
info!("set disk formats success, NoHealRequired, errs: {:?}", errs);
return Ok((result, Some(StorageError::NoHealRequired)));
}
if !dry_run {
for (disk_idx, err) in errs.iter().enumerate() {
if !matches!(err, Some(DiskError::UnformattedDisk)) {
continue;
}
let mut new_format = ref_format.clone();
new_format.erasure.this = ref_format.erasure.sets[self.set_index][disk_idx];
if save_format_file(&disks[disk_idx], &Some(new_format.clone())).await.is_ok() {
result.after.drives[disk_idx].uuid = new_format.erasure.this.to_string();
result.after.drives[disk_idx].state = DriveState::Ok.to_string();
}
}
}
Ok((result, None))
}
#[tracing::instrument(skip(self))]
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
let mut result = heal_bucket_local_on_disks(bucket, opts, self.disk_inventory().await).await?;
result.set_count = 1;
Ok(result)
}
#[tracing::instrument(skip(self))]
async fn heal_object(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
let _write_lock_guard = if !opts.no_lock {
let ns_lock = self.new_ns_lock(bucket, object).await?;
Some(
ns_lock
.get_write_lock(get_lock_acquire_timeout())
.await
.map_err(|e| self.map_namespace_lock_error(bucket, object, "write", e))?,
)
} else {
None
};
if has_suffix(object, SLASH_SEPARATOR) {
let (result, err) = self.heal_object_dir_locked(bucket, object, opts.dry_run, opts.remove).await?;
return Ok((result, err.map(|e| e.into())));
}
let disks = self.disks.read().await;
let disks = disks.clone();
let (_, errs) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
if DiskError::is_all_not_found(&errs) {
debug!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
bucket,
object,
version_id,
state = "missing_object_skipped",
"Set disk heal skipped missing object"
);
let err = if !version_id.is_empty() {
Error::FileVersionNotFound
} else {
Error::FileNotFound
};
return Ok((
self.default_heal_result(FileInfo::default(), &errs, bucket, object, version_id)
.await,
Some(err),
));
}
// Heal the object.
// Pass no_lock=true since we already obtained write lock (or are already called with no_lock=true)
let mut inner_opts = *opts;
inner_opts.no_lock = true;
let (result, err) = self
.heal_object(bucket, object, version_id, &inner_opts)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
if let Some(err) = err.as_ref() {
match err {
&DiskError::FileCorrupt if opts.scan_mode != HealScanMode::Deep => {
// Instead of returning an error when a bitrot error is detected
// during a normal heal scan, heal again with bitrot flag enabled.
inner_opts.scan_mode = HealScanMode::Deep;
let (result, err) = self
.heal_object(bucket, object, version_id, &inner_opts)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
return Ok((result, err.map(|e| e.into())));
}
_ => {}
}
}
Ok((result, err.map(|e| e.into())))
}
#[tracing::instrument(skip(self))]
async fn get_pool_and_set(&self, id: &str) -> Result<(Option<usize>, Option<usize>, Option<usize>)> {
for (set_idx, set) in self.format.erasure.sets.iter().enumerate() {
for (disk_idx, disk_id) in set.iter().enumerate() {
if disk_id.to_string() == id {
return Ok((Some(self.pool_index), Some(set_idx), Some(disk_idx)));
}
}
}
Err(Error::DiskNotFound)
}
#[tracing::instrument(skip(self))]
async fn check_abandoned_parts(&self, _bucket: &str, _object: &str, _opts: &HealOpts) -> Result<()> {
// Multipart orphan reconciliation is intentionally retained above the set layer
// until there is a concrete caller and a stable lower-level contract to implement.
Err(StorageError::NotImplemented)
}
}
#[derive(Debug, PartialEq, Eq)]
struct ObjProps {
successor_mod_time: Option<OffsetDateTime>,
num_versions: usize,
}
impl Hash for ObjProps {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.successor_mod_time.hash(state);
self.num_versions.hash(state);
}
}
#[derive(Default, Clone, Debug)]
pub struct HealEntryResult {
pub bytes: usize,
pub success: bool,
pub skipped: bool,
pub entry_done: bool,
pub name: String,
}
fn is_object_dangling(
meta_arr: &[FileInfo],
errs: &[Option<DiskError>],
data_errs_by_part: &HashMap<usize, Vec<usize>>,
) -> (FileInfo, bool) {
let (not_found_meta_errs, non_actionable_meta_errs) = dangling_meta_errs_count(errs);
let (mut not_found_parts_errs, mut non_actionable_parts_errs) = (0, 0);
data_errs_by_part.iter().for_each(|(_, v)| {
let (nf, na) = dangling_part_errs_count(v);
if nf > not_found_parts_errs {
(not_found_parts_errs, non_actionable_parts_errs) = (nf, na);
}
});
let mut valid_meta = FileInfo::default();
for fi in meta_arr.iter() {
if fi.is_valid() {
valid_meta = fi.clone();
break;
}
}
if !valid_meta.is_valid() {
let data_blocks = meta_arr.len().div_ceil(2);
if not_found_parts_errs > data_blocks {
return (valid_meta, true);
}
return (valid_meta, false);
}
if non_actionable_meta_errs > 0 || non_actionable_parts_errs > 0 {
return (valid_meta, false);
}
if valid_meta.deleted {
let data_blocks = errs.len().div_ceil(2);
return (valid_meta, not_found_meta_errs > data_blocks);
}
if not_found_meta_errs > 0 && not_found_meta_errs > valid_meta.erasure.parity_blocks {
return (valid_meta, true);
}
if !valid_meta.is_remote() && not_found_parts_errs > 0 && not_found_parts_errs > valid_meta.erasure.parity_blocks {
return (valid_meta, true);
}
(valid_meta, false)
}
fn dangling_meta_errs_count(cerrs: &[Option<DiskError>]) -> (usize, usize) {
let (mut not_found_count, mut non_actionable_count) = (0, 0);
cerrs.iter().for_each(|err| {
if let Some(err) = err {
if err == &DiskError::FileNotFound || err == &DiskError::FileVersionNotFound {
not_found_count += 1;
} else {
non_actionable_count += 1;
}
}
});
(not_found_count, non_actionable_count)
}
fn dangling_part_errs_count(results: &[usize]) -> (usize, usize) {
let (mut not_found_count, mut non_actionable_count) = (0, 0);
results.iter().for_each(|result| {
if *result == CHECK_PART_SUCCESS {
// skip
} else if *result == CHECK_PART_FILE_NOT_FOUND {
not_found_count += 1;
} else {
non_actionable_count += 1;
}
});
(not_found_count, non_actionable_count)
}
fn is_object_dir_dangling(errs: &[Option<DiskError>]) -> bool {
let mut found = 0;
let mut not_found = 0;
let mut found_not_empty = 0;
let mut other_found = 0;
errs.iter().for_each(|err| {
if err.is_none() {
found += 1;
} else if let Some(err) = err {
if err == &DiskError::FileNotFound || err == &DiskError::VolumeNotFound {
not_found += 1;
} else if err == &DiskError::VolumeNotEmpty {
found_not_empty += 1;
} else {
other_found += 1;
}
}
});
found = found + found_not_empty + other_found;
found < not_found && found > 0
}
fn join_errs(errs: &[Option<DiskError>]) -> String {
let errs = errs
.iter()
.map(|err| {
if let Some(err) = err {
return err.to_string();
}
"<nil>".to_string()
})
.collect::<Vec<_>>();
errs.join(", ")
}
/// disks_with_all_partsv2 is a corrected version based on Go implementation.
/// It sets partsMetadata and onlineDisks when xl.meta is inexistant/corrupted or outdated.
/// It also checks if the status of each part (corrupted, missing, ok) in each drive.
/// Returns (availableDisks, dataErrsByDisk, dataErrsByPart).
#[allow(clippy::too_many_arguments)]
async fn disks_with_all_parts(
online_disks: &mut [Option<DiskStore>],
parts_metadata: &mut [FileInfo],
errs: &[Option<DiskError>],
latest_meta: &FileInfo,
filter_by_etag: bool,
bucket: &str,
object: &str,
scan_mode: HealScanMode,
) -> disk::error::Result<(HashMap<usize, Vec<usize>>, HashMap<usize, Vec<usize>>)> {
let object_name = latest_meta.name.clone();
// Initialize dataErrsByDisk and dataErrsByPart with 0 (CHECK_PART_UNKNOWN) to match Go
let mut data_errs_by_disk: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..online_disks.len() {
data_errs_by_disk.insert(i, vec![CHECK_PART_UNKNOWN; latest_meta.parts.len()]);
}
let mut data_errs_by_part: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..latest_meta.parts.len() {
data_errs_by_part.insert(i, vec![CHECK_PART_UNKNOWN; online_disks.len()]);
}
// Check for inconsistent erasure distribution
let mut inconsistent = 0;
for (index, meta) in parts_metadata.iter().enumerate() {
if !meta.is_valid() {
// Since for majority of the cases erasure.Index matches with erasure.Distribution we can
// consider the offline disks as consistent.
continue;
}
if !meta.deleted {
if meta.erasure.distribution.len() != online_disks.len() {
// Erasure distribution seems to have lesser
// number of items than number of online disks.
inconsistent += 1;
continue;
}
if !meta.erasure.distribution.is_empty()
&& index < meta.erasure.distribution.len()
&& meta.erasure.distribution[index] != meta.erasure.index
{
// Mismatch indexes with distribution order
inconsistent += 1;
}
}
}
let erasure_distribution_reliable = inconsistent <= parts_metadata.len() / 2;
// Initialize metaErrs
let mut meta_errs = Vec::with_capacity(errs.len());
for _ in 0..errs.len() {
meta_errs.push(None);
}
let online_disks_len = online_disks.len();
// Process meta errors
for (index, disk_op) in online_disks.iter_mut().enumerate() {
if let Some(err) = &errs[index] {
meta_errs[index] = Some(err.clone());
continue;
}
if disk_op.is_none() {
meta_errs[index] = Some(DiskError::DiskNotFound);
continue;
}
let meta = &parts_metadata[index];
let corrupted = if filter_by_etag {
latest_meta.get_etag() != meta.get_etag()
} else {
!meta.mod_time.eq(&latest_meta.mod_time) || !meta.data_dir.eq(&latest_meta.data_dir)
};
if corrupted {
info!(
"disks_with_all_partsv2: metadata is corrupted, object_name={}, index: {index}",
object_name
);
meta_errs[index] = Some(DiskError::FileCorrupt);
parts_metadata[index] = FileInfo::default();
*disk_op = None;
continue;
}
if erasure_distribution_reliable {
if !meta.is_valid() {
info!(
"disks_with_all_partsv2: metadata is not valid, object_name={}, index: {index}",
object_name
);
parts_metadata[index] = FileInfo::default();
meta_errs[index] = Some(DiskError::FileCorrupt);
*disk_op = None;
continue;
}
if !meta.deleted && meta.erasure.distribution.len() != online_disks_len {
// Erasure distribution is not the same as onlineDisks
// attempt a fix if possible, assuming other entries
// might have the right erasure distribution.
info!(
"disks_with_all_partsv2: erasure distribution is not the same as onlineDisks, object_name={}, index: {index}",
object_name
);
parts_metadata[index] = FileInfo::default();
meta_errs[index] = Some(DiskError::FileCorrupt);
*disk_op = None;
continue;
}
}
}
// Copy meta errors to part errors
for (index, err) in meta_errs.iter().enumerate() {
if err.is_some() {
let part_err = conv_part_err_to_int(err);
for p in 0..latest_meta.parts.len() {
if let Some(vec) = data_errs_by_part.get_mut(&p)
&& index < vec.len()
{
vec[index] = part_err;
}
}
}
}
// Check data for each disk
for (index, disk) in online_disks.iter().enumerate() {
if meta_errs[index].is_some() {
continue;
}
let disk = if let Some(disk) = disk {
disk
} else {
continue;
};
let meta = &mut parts_metadata[index];
if meta.deleted || meta.is_remote() {
continue;
}
// Inline data is stored inside xl.meta, so there is no separate part file to
// verify here. Treat the shard as present once metadata was read successfully;
// object reads/heal will validate the inline shard through the normal bitrot
// reader path. Running bitrot_verify directly here can falsely mark small
// inline shards corrupt when older metadata has no per-part checksum entries.
if (meta.data.is_some() || meta.size == 0) && !meta.parts.is_empty() {
if let Some(vec) = data_errs_by_part.get_mut(&0)
&& index < vec.len()
{
vec[index] = CHECK_PART_SUCCESS;
}
continue;
}
// Verify file or check parts
let mut verify_resp = CheckPartsResp::default();
let mut verify_err = None;
meta.data_dir = latest_meta.data_dir;
if scan_mode == HealScanMode::Deep {
// disk has a valid xl.meta but may not have all the
// parts. This is considered an outdated disk, since
// it needs healing too.
match disk.verify_file(bucket, object, meta).await {
Ok(v) => {
verify_resp = v;
}
Err(err) => {
debug!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
object = %object_name,
disk_index = index,
state = "verify_failed",
error = ?err,
"Set disk verify_file failed"
);
verify_err = Some(err);
}
}
} else {
match disk.check_parts(bucket, object, meta).await {
Ok(v) => {
verify_resp = v;
}
Err(err) => {
debug!(
event = EVENT_SET_DISK_HEAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
object = %object_name,
disk_index = index,
state = "check_parts_failed",
error = ?err,
"Set disk check_parts failed"
);
verify_err = Some(err);
}
}
}
// Update dataErrsByPart for all parts
for p in 0..latest_meta.parts.len() {
if let Some(vec) = data_errs_by_part.get_mut(&p)
&& index < vec.len()
{
if verify_err.is_some() {
vec[index] = conv_part_err_to_int(&verify_err.clone());
} else {
// Fix: verify_resp.results length is based on meta.parts, not latest_meta.parts
// We need to check bounds to avoid panic
if p < verify_resp.results.len() {
vec[index] = verify_resp.results[p];
} else {
vec[index] = CHECK_PART_SUCCESS;
}
}
}
}
}
populate_data_errs_by_disk(&mut data_errs_by_disk, &data_errs_by_part);
Ok((data_errs_by_disk, data_errs_by_part))
}
fn populate_data_errs_by_disk(
data_errs_by_disk: &mut HashMap<usize, Vec<usize>>,
data_errs_by_part: &HashMap<usize, Vec<usize>>,
) {
for (part_index, part_errs) in data_errs_by_part {
for (disk_index, part_err) in part_errs.iter().enumerate() {
if let Some(disk_errs) = data_errs_by_disk.get_mut(&disk_index)
&& *part_index < disk_errs.len()
{
disk_errs[*part_index] = *part_err;
}
}
}
}
pub fn should_heal_object_on_disk(
err: &Option<DiskError>,
parts_errs: &[usize],
meta: &FileInfo,
latest_meta: &FileInfo,
) -> (bool, bool, Option<DiskError>) {
if let Some(err) = err
&& (err == &DiskError::FileNotFound || err == &DiskError::FileVersionNotFound || err == &DiskError::FileCorrupt)
{
return (true, true, Some(err.clone()));
}
if err.is_some() {
return (false, false, err.clone());
}
if !meta.equals(latest_meta) {
warn!(
"should_heal_object_on_disk: metadata is outdated, object_name={}, meta: {:?}, latest_meta: {:?}",
meta.name, meta, latest_meta
);
return (true, true, Some(DiskError::OutdatedXLMeta));
}
if !meta.deleted && !meta.is_remote() {
let err_vec = [CHECK_PART_FILE_NOT_FOUND, CHECK_PART_FILE_CORRUPT];
for part_err in parts_errs.iter() {
if err_vec.contains(part_err) {
return (true, false, Some(DiskError::PartMissingOrCorrupt));
}
}
}
(false, false, None)
}
async fn get_disks_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> Vec<rustfs_madmin::Disk> {
let mut ret = Vec::new();
for (i, pool) in disks.iter().enumerate() {
if let Some(disk) = pool {
let runtime_state = disk.runtime_state();
let offline_duration_seconds = disk.offline_duration_secs();
let capacity_snapshot = disk.last_capacity_snapshot();
if runtime_state.should_probe_for_admin() || runtime_state == disk::health_state::RuntimeDriveHealthState::Suspect {
match disk.disk_info(&DiskInfoOptions::default()).await {
Ok(res) => {
disk.record_capacity_probe(res.total, res.used, res.free);
ret.push(rustfs_madmin::Disk {
endpoint: eps[i].to_string(),
local: eps[i].is_local,
pool_index: eps[i].pool_idx,
set_index: eps[i].set_idx,
disk_index: eps[i].disk_idx,
state: "ok".to_owned(),
root_disk: res.root_disk,
drive_path: res.mount_path.clone(),
healing: res.healing,
scanning: res.scanning,
runtime_state: Some(runtime_state.as_str().to_string()),
offline_duration_seconds,
capacity_observation_source: Some("live_probe".to_owned()),
capacity_observation_age_seconds: Some(0),
uuid: res.id.map_or_else(|| "".to_string(), |id| id.to_string()),
major: res.major as u32,
minor: res.minor as u32,
model: None,
total_space: res.total,
used_space: res.used,
available_space: res.free,
physical_device_ids: (!res.physical_device_ids.is_empty()).then_some(res.physical_device_ids.clone()),
utilization: utilization_percent(res.total, res.used),
used_inodes: res.used_inodes,
free_inodes: res.free_inodes,
..Default::default()
});
}
Err(err) => {
let mut disk_info = rustfs_madmin::Disk {
state: err.to_string(),
endpoint: eps[i].to_string(),
local: eps[i].is_local,
pool_index: eps[i].pool_idx,
set_index: eps[i].set_idx,
disk_index: eps[i].disk_idx,
runtime_state: Some(runtime_state.as_str().to_string()),
offline_duration_seconds,
..Default::default()
};
if let Some((total, used, free, _)) = capacity_snapshot {
disk_info.total_space = total;
disk_info.used_space = used;
disk_info.available_space = free;
disk_info.utilization = utilization_percent(total, used);
disk_info.capacity_observation_source = Some("snapshot".to_owned());
disk_info.capacity_observation_age_seconds = capacity_snapshot
.map(|(_, _, _, probe_unix_secs)| capacity_snapshot_age_seconds(probe_unix_secs));
} else {
disk_info.capacity_observation_source = Some("missing".to_owned());
disk_info.capacity_observation_age_seconds = Some(0);
}
ret.push(disk_info);
}
}
} else {
ret.push(build_runtime_snapshot_disk(
&eps[i],
runtime_state,
offline_duration_seconds,
capacity_snapshot,
));
}
} else {
ret.push(rustfs_madmin::Disk {
endpoint: eps[i].to_string(),
local: eps[i].is_local,
pool_index: eps[i].pool_idx,
set_index: eps[i].set_idx,
disk_index: eps[i].disk_idx,
runtime_state: None,
offline_duration_seconds: None,
state: DiskError::DiskNotFound.to_string(),
capacity_observation_source: Some("missing".to_owned()),
capacity_observation_age_seconds: Some(0),
..Default::default()
})
}
}
ret
}
fn build_runtime_snapshot_disk(
endpoint: &Endpoint,
runtime_state: disk::health_state::RuntimeDriveHealthState,
offline_duration_seconds: Option<u64>,
capacity_snapshot: Option<(u64, u64, u64, u64)>,
) -> rustfs_madmin::Disk {
let mut disk = rustfs_madmin::Disk {
endpoint: endpoint.to_string(),
local: endpoint.is_local,
pool_index: endpoint.pool_idx,
set_index: endpoint.set_idx,
disk_index: endpoint.disk_idx,
state: runtime_state.as_str().to_string(),
runtime_state: Some(runtime_state.as_str().to_string()),
offline_duration_seconds,
..Default::default()
};
if let Some((total, used, free, _)) = capacity_snapshot {
disk.total_space = total;
disk.used_space = used;
disk.available_space = free;
disk.utilization = utilization_percent(total, used);
disk.capacity_observation_source = Some("snapshot".to_owned());
disk.capacity_observation_age_seconds =
capacity_snapshot.map(|(_, _, _, probe_unix_secs)| capacity_snapshot_age_seconds(probe_unix_secs));
} else {
disk.capacity_observation_source = Some("missing".to_owned());
disk.capacity_observation_age_seconds = Some(0);
}
disk
}
fn utilization_percent(total: u64, used: u64) -> f64 {
if total > 0 {
used as f64 / total as f64 * 100_f64
} else {
0_f64
}
}
fn capacity_snapshot_age_seconds(probe_unix_secs: u64) -> u64 {
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|dur| dur.as_secs())
.unwrap_or(probe_unix_secs);
now_unix_secs.saturating_sub(probe_unix_secs)
}
async fn get_storage_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> rustfs_madmin::StorageInfo {
// let mut disks = get_disks_info(disks, eps).await;
// disks.sort_by(|a, b| a.total_space.cmp(&b.total_space));
//
// rustfs_madmin::StorageInfo {
// disks,
// backend: rustfs_madmin::BackendInfo {
// backend_type: rustfs_madmin::BackendByte::Erasure,
// ..Default::default()
// },
// }
let mut disks = get_disks_info(disks, eps).await;
disks.sort_by_key(|a| a.total_space);
// Provide minimal backend shape for callers. Do NOT guess parity here since it belongs to higher-level config.
// Missing/empty standard_sc_data will be handled by capacity fallback logic.
let drives_per_set = vec![eps.len()];
let total_sets = vec![1];
rustfs_madmin::StorageInfo {
disks,
backend: rustfs_madmin::BackendInfo {
backend_type: rustfs_madmin::BackendByte::Erasure,
drives_per_set,
total_sets,
..Default::default()
},
}
}
pub async fn stat_all_dirs(disks: &[Option<DiskStore>], bucket: &str, prefix: &str) -> Vec<Option<DiskError>> {
let mut futures = Vec::with_capacity(disks.len());
// Spawn one future per disk slot so the returned vector stays index-aligned with `disks`
// (and therefore with `set_endpoints`). Offline/None disks must yield DiskNotFound in-place
// rather than being skipped, otherwise callers that zip `errs` against the full disks array
// (heal_object_dir) would pair every error with the wrong disk/endpoint whenever any disk is
// offline — and could `make_volume` on the wrong disk.
for disk in disks.iter() {
let disk = disk.clone();
let bucket = bucket.to_string();
let prefix = prefix.to_string();
futures.push(tokio::spawn(async move {
let Some(disk) = disk else {
return Some(DiskError::DiskNotFound);
};
match disk.list_dir("", &bucket, &prefix, 1).await {
Ok(entries) => {
if !entries.is_empty() {
return Some(DiskError::VolumeNotEmpty);
}
None
}
Err(err) => Some(err),
}
}));
}
let results = join_all(futures).await;
// Preserve length/index alignment: a panicked probe becomes a corrupt-state error instead of
// a silently-dropped slot that would re-shift every subsequent index.
let mut errs = Vec::with_capacity(disks.len());
for res in results.into_iter() {
match res {
Ok(err) => errs.push(err),
Err(join_err) => errs.push(Some(DiskError::other(join_err.to_string()))),
}
}
errs
}
const GLOBAL_MIN_PART_SIZE: ByteSize = ByteSize::mib(5);
fn is_min_allowed_part_size(size: i64) -> bool {
size >= GLOBAL_MIN_PART_SIZE.as_u64() as i64
}
fn get_complete_multipart_md5(parts: &[CompletePart]) -> String {
let mut buf = Vec::new();
for part in parts.iter() {
if let Some(etag) = &part.etag {
if let Ok(etag_bytes) = hex_simd::decode_to_vec(etag.as_bytes()) {
buf.extend(etag_bytes);
} else {
buf.extend(etag.bytes());
}
}
}
let mut hasher = Md5::new();
hasher.update(&buf);
let digest = hasher.finalize();
let etag_hex = faster_hex::hex_string(digest.as_slice());
format!("{}-{}", etag_hex, parts.len())
}
fn completed_multipart_object_part(part_num: usize, ext_part: &ObjectPartInfo) -> ObjectPartInfo {
ObjectPartInfo {
etag: ext_part.etag.clone(),
number: part_num,
size: ext_part.size,
mod_time: ext_part.mod_time,
actual_size: ext_part.actual_size,
index: ext_part.index.clone(),
checksums: ext_part.checksums.clone(),
..Default::default()
}
}
fn complete_part_checksum(part: &CompletePart, checksum_type: rustfs_rio::ChecksumType) -> Option<Option<String>> {
match checksum_type.base() {
rustfs_rio::ChecksumType::SHA256 => Some(part.checksum_sha256.clone()),
rustfs_rio::ChecksumType::SHA1 => Some(part.checksum_sha1.clone()),
rustfs_rio::ChecksumType::CRC32 => Some(part.checksum_crc32.clone()),
rustfs_rio::ChecksumType::CRC32C => Some(part.checksum_crc32c.clone()),
rustfs_rio::ChecksumType::CRC64_NVME => Some(part.checksum_crc64nvme.clone()),
_ => None,
}
}
fn parts_after_marker(part_numbers: &[usize], part_number_marker: usize) -> Option<&[usize]> {
if part_number_marker == 0 {
return Some(part_numbers);
}
part_numbers
.iter()
.position(|&part_number| part_number != 0 && part_number == part_number_marker)
.map(|index| &part_numbers[index + 1..])
}
pub fn canonicalize_etag(etag: &str) -> String {
let re = Regex::new("\"*?([^\"]*?)\"*?$").unwrap();
re.replace_all(etag, "$1").to_string()
}
pub fn e_tag_matches(etag: &str, condition: &str) -> bool {
if condition.trim() == "*" {
return true;
}
canonicalize_etag(etag) == canonicalize_etag(condition)
}
pub fn should_prevent_write(oi: &ObjectInfo, if_none_match: Option<String>, if_match: Option<String>) -> bool {
let if_none_match = if_none_match
.as_deref()
.map(str::trim)
.filter(|condition| !condition.is_empty());
let if_match = if_match.as_deref().map(str::trim).filter(|condition| !condition.is_empty());
match &oi.etag {
Some(etag) => {
if let Some(if_none_match) = if_none_match
&& e_tag_matches(etag, if_none_match)
{
return true;
}
if let Some(if_match) = if_match
&& !e_tag_matches(etag, if_match)
{
return true;
}
false
}
// If we can't obtain the etag of the object, perevent the write only when we have at least one condition
None => if_none_match.is_some() || if_match.is_some(),
}
}
/// Validates if the given storage class is supported
pub fn is_valid_storage_class(storage_class: &str) -> bool {
matches!(
storage_class,
storageclass::STANDARD
| storageclass::RRS
| storageclass::DEEP_ARCHIVE
| storageclass::EXPRESS_ONEZONE
| storageclass::GLACIER
| storageclass::GLACIER_IR
| storageclass::INTELLIGENT_TIERING
| storageclass::ONEZONE_IA
| storageclass::OUTPOSTS
| storageclass::SNOW
| storageclass::STANDARD_IA
)
}
/// Returns true if the storage class is a cold storage tier that requires special handling
pub fn is_cold_storage_class(storage_class: &str) -> bool {
matches!(
storage_class,
storageclass::DEEP_ARCHIVE | storageclass::GLACIER | storageclass::GLACIER_IR
)
}
/// Returns true if the storage class is an infrequent access tier
pub fn is_infrequent_access_class(storage_class: &str) -> bool {
matches!(
storage_class,
storageclass::ONEZONE_IA | storageclass::STANDARD_IA | storageclass::INTELLIGENT_TIERING
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::replication::{replication_statuses_map, version_purge_statuses_map};
use crate::disk::CHECK_PART_UNKNOWN;
use crate::disk::CHECK_PART_VOLUME_NOT_FOUND;
use crate::disk::DiskOption;
use crate::disk::RUSTFS_META_BUCKET;
use crate::disk::RUSTFS_META_TMP_BUCKET;
use crate::disk::STORAGE_FORMAT_FILE;
use crate::disk::STORAGE_FORMAT_FILE_BACKUP;
use crate::disk::WalkDirOptions;
use crate::disk::endpoint::Endpoint;
use crate::disk::error::DiskError;
use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::new_disk;
use crate::layout::endpoints::SetupType;
use crate::object_api::ObjectInfo;
use crate::storage_api_contracts::{
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
namespace::NamespaceLocking as _, object::ObjectOperations as _,
};
use crate::store::init_format::save_format_file;
use crate::store::list_objects::ListPathOptions;
use rustfs_filemeta::ErasureInfo;
use rustfs_filemeta::FileMeta;
use rustfs_filemeta::MetaCacheEntry;
use rustfs_lock::client::local::LocalClient;
use rustfs_lock::{LockError, LockInfo, LockResponse, LockStats};
use serial_test::serial;
use std::collections::HashMap;
use tempfile::TempDir;
use time::OffsetDateTime;
use tokio::fs;
#[test]
fn complete_part_error_maps_confirmed_missing_to_invalid_part() {
for err in ["file not found", "Specified part could not be found", "part.7 not found"] {
let mapped = complete_multipart_part_error(7, err, "bucket", "object");
assert!(matches!(
mapped,
Error::InvalidPart(7, ref bucket, ref object) if bucket == "bucket" && object == "object"
));
assert_eq!(complete_multipart_part_error_result(&mapped), COMPLETE_MULTIPART_PART_MISSING);
}
}
#[test]
fn complete_part_error_maps_read_quorum_to_retryable_server_error() {
let mapped = complete_multipart_part_error(1, "erasure read quorum", "bucket", "object");
assert!(matches!(
mapped,
Error::InsufficientReadQuorum(ref bucket, ref object) if bucket == "bucket" && object == "object"
));
assert_eq!(
complete_multipart_part_error_result(&mapped),
COMPLETE_MULTIPART_PART_READ_QUORUM_UNAVAILABLE
);
}
#[test]
fn complete_part_error_maps_unknown_part_error_to_retryable_server_error() {
let mapped = complete_multipart_part_error(1, "metadata decode failed", "bucket", "object");
assert!(matches!(mapped, Error::InsufficientReadQuorum(_, _)));
assert_ne!(complete_multipart_part_error_result(&mapped), COMPLETE_MULTIPART_PART_MISSING);
}
#[derive(Debug, Default)]
struct FailingClient;
#[async_trait::async_trait]
impl LockClient for FailingClient {
async fn acquire_lock(&self, _request: &rustfs_lock::LockRequest) -> rustfs_lock::Result<LockResponse> {
Err(LockError::internal("simulated offline client"))
}
async fn release(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
Ok(false)
}
async fn refresh(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
Ok(false)
}
async fn force_release(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
Ok(false)
}
async fn check_status(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<Option<LockInfo>> {
Ok(None)
}
async fn get_stats(&self) -> rustfs_lock::Result<LockStats> {
Ok(LockStats::default())
}
async fn close(&self) -> rustfs_lock::Result<()> {
Ok(())
}
async fn is_online(&self) -> bool {
false
}
async fn is_local(&self) -> bool {
false
}
}
#[derive(Debug)]
struct DelayedBatchClient {
inner: Arc<dyn LockClient>,
delay: Duration,
}
#[async_trait::async_trait]
impl LockClient for DelayedBatchClient {
async fn acquire_lock(&self, request: &rustfs_lock::LockRequest) -> rustfs_lock::Result<LockResponse> {
self.inner.acquire_lock(request).await
}
async fn acquire_locks_batch(&self, requests: &[rustfs_lock::LockRequest]) -> rustfs_lock::Result<Vec<LockResponse>> {
tokio::time::sleep(self.delay).await;
self.inner.acquire_locks_batch(requests).await
}
async fn release(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
self.inner.release(lock_id).await
}
async fn release_locks_batch(&self, lock_ids: &[rustfs_lock::LockId]) -> rustfs_lock::Result<Vec<bool>> {
self.inner.release_locks_batch(lock_ids).await
}
async fn refresh(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
self.inner.refresh(lock_id).await
}
async fn force_release(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<bool> {
self.inner.force_release(lock_id).await
}
async fn check_status(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result<Option<LockInfo>> {
self.inner.check_status(lock_id).await
}
async fn get_stats(&self) -> rustfs_lock::Result<LockStats> {
self.inner.get_stats().await
}
async fn close(&self) -> rustfs_lock::Result<()> {
self.inner.close().await
}
async fn is_online(&self) -> bool {
self.inner.is_online().await
}
async fn is_local(&self) -> bool {
self.inner.is_local().await
}
}
async fn make_test_set_disks(lockers: Vec<Arc<dyn LockClient>>) -> Arc<SetDisks> {
let endpoints = vec![
Endpoint::try_from("http://127.0.0.1:9000/data").expect("first endpoint should parse"),
Endpoint::try_from("http://127.0.0.1:9001/data").expect("second endpoint should parse"),
];
SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(vec![None, None])),
2,
1,
0,
0,
endpoints,
FormatV3::new(1, 2),
lockers,
)
.await
}
struct SetupTypeGuard {
previous: SetupType,
}
impl SetupTypeGuard {
async fn switch_to(next: SetupType) -> Self {
let previous = current_setup_type().await;
runtime_sources::set_setup_type(next).await;
Self { previous }
}
}
impl Drop for SetupTypeGuard {
fn drop(&mut self) {
let previous = self.previous.clone();
let handle = tokio::runtime::Handle::current();
tokio::task::block_in_place(|| {
handle.block_on(async move {
runtime_sources::set_setup_type(previous).await;
});
});
}
}
async fn current_setup_type() -> SetupType {
runtime_sources::current_setup_type().await
}
async fn make_formatted_local_disk_for_info_test(disk_idx: usize, format: &FormatV3) -> (TempDir, Endpoint, DiskStore) {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
(dir, endpoint, disk)
}
#[tokio::test]
async fn test_rename_data_quorum_failure_rolls_back_destination_object() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let disk_root = dir.path().join("disk0");
fs::create_dir_all(&disk_root).await.expect("disk root should be created");
let endpoint = Endpoint::try_from(disk_root.to_str().expect("disk path should be utf8")).expect("endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let bucket = "bucket";
let object = "object";
let tmp_object = "tmp-object";
let version_id = Uuid::parse_str("77777777-7777-7777-7777-777777777777").expect("version id should parse");
let old_data_dir = Uuid::parse_str("88888888-8888-8888-8888-888888888888").expect("old data dir should parse");
let new_data_dir = Uuid::parse_str("99999999-9999-9999-9999-999999999999").expect("new data dir should parse");
match disk.make_volume(bucket).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(err) => panic!("bucket should be available: {err:?}"),
}
match disk.make_volume(RUSTFS_META_TMP_BUCKET).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(err) => panic!("tmp bucket should be available: {err:?}"),
}
let object_dir = disk_root.join(bucket).join(object);
fs::create_dir_all(object_dir.join(old_data_dir.to_string()))
.await
.expect("old data dir should be created");
let mut old_fi = FileInfo::new(&format!("{bucket}/{object}"), 1, 1);
old_fi.name = object.to_string();
old_fi.version_id = Some(version_id);
old_fi.data_dir = Some(old_data_dir);
old_fi.size = 1;
old_fi.mod_time = Some(OffsetDateTime::now_utc());
let mut old_meta = FileMeta::default();
old_meta.add_version(old_fi).expect("old metadata should accept file info");
let old_meta_buf = old_meta.marshal_msg().expect("old metadata should encode");
fs::write(object_dir.join(STORAGE_FORMAT_FILE), old_meta_buf.clone())
.await
.expect("old metadata should be written");
let tmp_data_dir = disk_root
.join(RUSTFS_META_TMP_BUCKET)
.join(tmp_object)
.join(new_data_dir.to_string());
fs::create_dir_all(&tmp_data_dir)
.await
.expect("new tmp data dir should be created");
fs::write(tmp_data_dir.join("part.1"), b"new")
.await
.expect("new tmp part should be written");
let mut new_fi = FileInfo::new(&format!("{bucket}/{object}"), 1, 1);
new_fi.name = object.to_string();
new_fi.version_id = Some(version_id);
new_fi.data_dir = Some(new_data_dir);
new_fi.size = 1;
new_fi.mod_time = Some(OffsetDateTime::now_utc());
let disks = vec![Some(disk), None];
let file_infos = vec![new_fi.clone(), new_fi];
let result = SetDisks::rename_data(&disks, RUSTFS_META_TMP_BUCKET, tmp_object, &file_infos, bucket, object, 2).await;
assert!(result.is_err());
let restored_meta = fs::read(object_dir.join(STORAGE_FORMAT_FILE))
.await
.expect("destination metadata should remain readable");
assert_eq!(restored_meta, old_meta_buf);
assert!(!object_dir.join(object).join(STORAGE_FORMAT_FILE).exists());
assert!(!object_dir.join(new_data_dir.to_string()).exists());
assert!(
!object_dir
.join(old_data_dir.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP)
.exists()
);
}
#[test]
fn disk_health_entry_returns_cached_value_within_ttl() {
let entry = DiskHealthEntry {
last_check: Instant::now(),
online: true,
};
assert_eq!(entry.cached_value(), Some(true));
}
#[test]
fn disk_health_entry_expires_after_ttl() {
let entry = DiskHealthEntry {
last_check: Instant::now() - (DISK_HEALTH_CACHE_TTL + Duration::from_millis(100)),
online: true,
};
assert!(entry.cached_value().is_none());
}
#[test]
fn test_check_part_constants() {
// Test that all CHECK_PART constants have expected values
assert_eq!(CHECK_PART_UNKNOWN, 0);
assert_eq!(CHECK_PART_SUCCESS, 1);
assert_eq!(CHECK_PART_FILE_NOT_FOUND, 4); // The actual value is 4, not 2
assert_eq!(CHECK_PART_VOLUME_NOT_FOUND, 3);
assert_eq!(CHECK_PART_FILE_CORRUPT, 5);
}
#[test]
fn test_is_min_allowed_part_size() {
// Test minimum part size validation
assert!(!is_min_allowed_part_size(0));
assert!(!is_min_allowed_part_size(1024)); // 1KB - too small
assert!(!is_min_allowed_part_size(1024 * 1024)); // 1MB - too small
assert!(is_min_allowed_part_size(5 * 1024 * 1024)); // 5MB - minimum allowed
assert!(is_min_allowed_part_size(10 * 1024 * 1024)); // 10MB - allowed
assert!(is_min_allowed_part_size(100 * 1024 * 1024)); // 100MB - allowed
}
#[test]
fn resolve_delete_version_state_clears_delete_marker_for_replica_marker_version_purge() {
let opts = ObjectOptions {
versioned: true,
version_id: Some(Uuid::new_v4().to_string()),
delete_replication: Some(ReplicationState {
replica_status: ReplicationStatusType::Replica,
..Default::default()
}),
..Default::default()
};
let current = ObjectInfo {
version_id: Some(Uuid::new_v4()),
delete_marker: true,
..Default::default()
};
let (mark_delete, delete_marker) = resolve_delete_version_state(&opts, &current, true);
assert!(!mark_delete);
assert!(
!delete_marker,
"replica purge of an existing delete marker version must remove that version, not preserve delete-marker semantics"
);
}
#[test]
fn resolve_delete_version_state_keeps_delete_marker_for_replica_marker_creation() {
let opts = ObjectOptions {
versioned: true,
version_id: Some(Uuid::new_v4().to_string()),
delete_marker: true,
delete_replication: Some(ReplicationState {
replica_status: ReplicationStatusType::Replica,
..Default::default()
}),
..Default::default()
};
let (mark_delete, delete_marker) = resolve_delete_version_state(&opts, &ObjectInfo::default(), false);
assert!(!mark_delete);
assert!(delete_marker);
}
#[test]
fn resolve_delete_version_state_creates_marker_for_missing_latest_versioned_delete() {
let opts = ObjectOptions {
versioned: true,
..Default::default()
};
let (mark_delete, delete_marker) = resolve_delete_version_state(&opts, &ObjectInfo::default(), false);
assert!(mark_delete);
assert!(delete_marker);
}
#[test]
fn should_force_delete_marker_for_missing_version_rejects_data_movement_latest_delete() {
let opts = ObjectOptions {
versioned: true,
data_movement: true,
..Default::default()
};
assert!(!should_force_delete_marker_for_missing_version(&opts));
}
#[test]
fn should_force_delete_marker_for_missing_version_allows_explicit_marker_creation() {
let opts = ObjectOptions {
versioned: true,
data_movement: true,
delete_marker: true,
..Default::default()
};
assert!(should_force_delete_marker_for_missing_version(&opts));
}
#[test]
fn resolve_delete_version_state_skips_marker_creation_for_replica_purge_when_version_missing() {
let opts = ObjectOptions {
versioned: true,
version_id: Some(Uuid::new_v4().to_string()),
delete_replication: Some(ReplicationState {
replica_status: ReplicationStatusType::Replica,
..Default::default()
}),
..Default::default()
};
let (mark_delete, delete_marker) = resolve_delete_version_state(&opts, &ObjectInfo::default(), false);
assert!(
!mark_delete,
"replica delete-marker purges should not schedule mark-delete writes when the target version is absent"
);
assert!(
!delete_marker,
"replica delete-marker purges must become no-ops when the marker version has not arrived on the target yet"
);
}
#[test]
fn should_preserve_delete_replication_state_for_completed_delete_marker_replication_update() {
let opts = ObjectOptions {
version_id: Some(Uuid::new_v4().to_string()),
delete_replication: Some(ReplicationState {
replicate_decision_str: "target=true;false;target;".to_string(),
replication_status_internal: Some("target=COMPLETED;".to_string()),
targets: replication_statuses_map("target=COMPLETED;"),
..Default::default()
}),
..Default::default()
};
assert!(
should_preserve_delete_replication_state(&opts),
"source delete-marker replication status updates must not be re-evaluated as fresh delete replication requests"
);
}
#[test]
fn should_not_preserve_delete_replication_state_for_new_version_delete_request() {
let opts = ObjectOptions {
version_id: Some(Uuid::new_v4().to_string()),
..Default::default()
};
assert!(
!should_preserve_delete_replication_state(&opts),
"fresh versioned deletes still need replication eligibility checks"
);
}
#[test]
fn resolve_delete_version_state_removes_source_delete_marker_version_during_purge_replication() {
let opts = ObjectOptions {
versioned: true,
version_id: Some(Uuid::new_v4().to_string()),
delete_replication: Some(ReplicationState {
version_purge_status_internal: Some("target=PENDING;".to_string()),
purge_targets: version_purge_statuses_map("target=PENDING;"),
..Default::default()
}),
..Default::default()
};
let current = ObjectInfo {
version_id: Some(Uuid::new_v4()),
delete_marker: true,
..Default::default()
};
let (mark_delete, delete_marker) = resolve_delete_version_state(&opts, &current, true);
assert!(
!mark_delete,
"source delete-marker version purge should delete the local marker instead of rewriting it with purge metadata"
);
assert!(
!delete_marker,
"source delete-marker version purge should not leave delete-marker semantics behind locally"
);
}
#[test]
fn test_get_complete_multipart_md5() {
// Test MD5 calculation for multipart upload
let parts = vec![
CompletePart {
part_num: 1,
etag: Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
checksum_crc32: None,
checksum_crc32c: None,
checksum_sha1: None,
checksum_sha256: None,
checksum_crc64nvme: None,
},
CompletePart {
part_num: 2,
etag: Some("098f6bcd4621d373cade4e832627b4f6".to_string()),
checksum_crc32: None,
checksum_crc32c: None,
checksum_sha1: None,
checksum_sha256: None,
checksum_crc64nvme: None,
},
];
let md5 = get_complete_multipart_md5(&parts);
assert!(md5.ends_with("-2")); // Should end with part count
assert!(md5.len() > 10); // Should have reasonable length
// Test with empty parts
let empty_parts = vec![];
let empty_result = get_complete_multipart_md5(&empty_parts);
assert!(empty_result.ends_with("-0"));
// Test with single part
let single_part = vec![CompletePart {
part_num: 1,
etag: Some("d41d8cd98f00b204e9800998ecf8427e".to_string()),
checksum_crc32: None,
checksum_crc32c: None,
checksum_sha1: None,
checksum_sha256: None,
checksum_crc64nvme: None,
}];
let single_result = get_complete_multipart_md5(&single_part);
assert!(single_result.ends_with("-1"));
}
#[test]
fn test_completed_multipart_object_part_preserves_checksums() {
let checksums = HashMap::from([
(rustfs_rio::ChecksumType::CRC32.to_string(), "crc32-value".to_string()),
(rustfs_rio::ChecksumType::CRC32C.to_string(), "crc32c-value".to_string()),
]);
let ext_part = ObjectPartInfo {
number: 7,
etag: "etag-7".to_string(),
size: 123,
actual_size: 456,
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
index: Some(Bytes::from_static(&[1, 2, 3])),
checksums: Some(checksums.clone()),
..Default::default()
};
let completed = completed_multipart_object_part(7, &ext_part);
assert_eq!(completed.number, 7);
assert_eq!(completed.etag, ext_part.etag);
assert_eq!(completed.size, ext_part.size);
assert_eq!(completed.actual_size, ext_part.actual_size);
assert_eq!(completed.index, ext_part.index);
assert_eq!(completed.checksums, Some(checksums));
}
#[test]
fn test_get_upload_id_dir() {
// Test upload ID directory path generation
let dir = SetDisks::get_upload_id_dir("bucket", "object", "upload-id");
// The function returns SHA256 hash of bucket/object + upload_id processing
assert!(dir.len() > 64); // Should be longer than just SHA256 hash
assert!(dir.contains("/")); // Should contain path separator
// Test with base64 encoded upload ID
let result2 = SetDisks::get_upload_id_dir("bucket", "object", "dXBsb2FkLWlk"); // base64 for "upload-id"
assert!(!result2.is_empty());
assert!(result2.len() > 10);
}
#[test]
fn test_get_multipart_sha_dir() {
// Test multipart SHA directory path generation
let dir = SetDisks::get_multipart_sha_dir("bucket", "object");
// The function returns SHA256 hash of "bucket/object"
assert_eq!(dir.len(), 64); // SHA256 hash length
assert!(!dir.contains("bucket")); // Should be hash, not original text
assert!(!dir.contains("object")); // Should be hash, not original text
// Test with empty strings
let result2 = SetDisks::get_multipart_sha_dir("", "");
assert!(!result2.is_empty());
assert_eq!(result2.len(), 64); // SHA256 hex string length
// Test that different inputs produce different hashes
let result3 = SetDisks::get_multipart_sha_dir("bucket1", "object1");
let result4 = SetDisks::get_multipart_sha_dir("bucket2", "object2");
assert_ne!(result3, result4);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_new_ns_lock_distributed_read_succeeds_with_two_lockers_one_offline() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let healthy_client: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager));
let failing_client: Arc<dyn LockClient> = Arc::new(FailingClient);
let set_disks = make_test_set_disks(vec![healthy_client, failing_client]).await;
let guard = set_disks
.new_ns_lock("bucket", "object")
.await
.expect("namespace lock should be created")
.get_read_lock(Duration::from_millis(100))
.await
.expect("read lock should succeed with one healthy locker");
match guard {
NamespaceLockGuard::Standard(_) => {}
NamespaceLockGuard::Fast(_) => panic!("Expected distributed guard for dist-erasure"),
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_new_ns_lock_distributed_write_fails_with_two_lockers_one_offline() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let healthy_client: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager));
let failing_client: Arc<dyn LockClient> = Arc::new(FailingClient);
let set_disks = make_test_set_disks(vec![healthy_client, failing_client]).await;
let err = set_disks
.new_ns_lock("bucket", "object")
.await
.expect("namespace lock should be created")
.get_write_lock(Duration::from_millis(100))
.await
.expect_err("write lock should fail with one healthy locker");
let err_str = err.to_string().to_lowercase();
assert!(
err_str.contains("quorum") || err_str.contains("not reached"),
"expected quorum error, got: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn copy_object_honors_no_lock_when_outer_write_lock_is_held() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
let _outer_guard = set_disks
.new_ns_lock("bucket", "object")
.await
.expect("namespace lock should be created")
.get_write_lock(Duration::from_secs(1))
.await
.expect("outer write lock should be acquired");
let mut src_info = ObjectInfo {
metadata_only: true,
..Default::default()
};
let dst_opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let result = timeout(
Duration::from_secs(1),
set_disks.copy_object(
"bucket",
"object",
"bucket",
"object",
&mut src_info,
&ObjectOptions::default(),
&dst_opts,
),
)
.await
.expect("no_lock copy path must not wait for the outer lock");
let err = result.expect_err("empty test disks should fail after bypassing the inner lock");
assert!(
!err.to_string().to_ascii_lowercase().contains("lock"),
"copy_object returned a lock error despite no_lock=true: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn copy_object_rejects_metadata_only_cross_key() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
let mut src_info = ObjectInfo {
metadata_only: true,
..Default::default()
};
let err = set_disks
.copy_object(
"bucket",
"source",
"bucket",
"dest",
&mut src_info,
&ObjectOptions::default(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect_err("metadata-only lower copy is only valid for self-copy updates");
assert!(matches!(err, StorageError::NotImplemented));
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn delete_object_honors_no_lock_when_outer_write_lock_is_held() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
let _outer_guard = set_disks
.new_ns_lock("bucket", "object")
.await
.expect("namespace lock should be created")
.get_write_lock(Duration::from_secs(1))
.await
.expect("outer write lock should be acquired");
let result = timeout(
Duration::from_secs(1),
set_disks.delete_object(
"bucket",
"object",
ObjectOptions {
no_lock: true,
..Default::default()
},
),
)
.await
.expect("no_lock delete path must not wait for the outer lock");
let err = result.expect_err("empty test disks should fail after bypassing the inner lock");
assert!(
!err.to_string().to_ascii_lowercase().contains("lock"),
"delete_object returned a lock error despite no_lock=true: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn delete_prefix_does_not_lock_literal_prefix_key() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
let _outer_guard = set_disks
.new_ns_lock("bucket", "prefix")
.await
.expect("namespace lock should be created")
.get_write_lock(Duration::from_secs(1))
.await
.expect("outer write lock should be acquired");
timeout(
Duration::from_secs(1),
set_disks.delete_object(
"bucket",
"prefix",
ObjectOptions {
delete_prefix: true,
..Default::default()
},
),
)
.await
.expect("broad prefix delete must not wait on a literal prefix namespace lock")
.expect("empty test disks should allow broad prefix cleanup");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn delete_prefix_object_honors_no_lock_when_outer_write_lock_is_held() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
let _outer_guard = set_disks
.new_ns_lock("bucket", "object")
.await
.expect("namespace lock should be created")
.get_write_lock(Duration::from_secs(1))
.await
.expect("outer write lock should be acquired");
timeout(
Duration::from_secs(1),
set_disks.delete_object(
"bucket",
"object",
ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
no_lock: true,
..Default::default()
},
),
)
.await
.expect("no_lock exact prefix delete path must not wait for the outer lock")
.expect("empty test disks should allow exact prefix cleanup");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn delete_prefix_object_locks_real_object_key() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
let _outer_guard = set_disks
.new_ns_lock("bucket", "object")
.await
.expect("namespace lock should be created")
.get_write_lock(Duration::from_secs(1))
.await
.expect("outer write lock should be acquired");
let result = timeout(
Duration::from_millis(50),
set_disks.delete_object(
"bucket",
"object",
ObjectOptions {
delete_prefix: true,
delete_prefix_object: true,
..Default::default()
},
),
)
.await;
assert!(result.is_err(), "exact prefix delete should wait on the real object namespace lock");
}
async fn make_single_local_disk() -> (TempDir, DiskStore) {
let dir = tempfile::tempdir().expect("tempdir should be created");
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
(dir, disk)
}
async fn make_set_disks_with(disks: Vec<Option<DiskStore>>) -> Arc<SetDisks> {
let drive_count = disks.len();
let endpoints = (0..drive_count)
.map(|i| Endpoint::try_from(format!("http://127.0.0.1:{}/data", 9000 + i).as_str()).expect("endpoint should parse"))
.collect::<Vec<_>>();
SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
drive_count,
0,
0,
0,
endpoints,
FormatV3::new(1, drive_count),
vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))],
)
.await
}
// issue #4189: an orphan directory tree (empty dirs, no xl.meta) must be purged.
#[tokio::test]
async fn purge_orphan_dir_object_removes_empty_tree() {
let (dir, disk) = make_single_local_disk().await;
let root = dir.path();
fs::create_dir_all(root.join("bucket").join("pfx").join("a").join("b"))
.await
.expect("nested empty dir should be created");
fs::create_dir_all(root.join("bucket").join("pfx").join("c"))
.await
.expect("sibling empty dir should be created");
let set = make_set_disks_with(vec![Some(disk)]).await;
let purged = set
.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("purge should succeed");
assert!(purged, "orphan empty tree should be purged");
assert!(!root.join("bucket").join("pfx").exists(), "prefix directory should be gone");
assert!(root.join("bucket").exists(), "bucket volume should remain");
}
// issue #4189: a prefix that still anchors a real object must be left intact.
#[tokio::test]
async fn purge_orphan_dir_object_preserves_prefix_with_object() {
let (dir, disk) = make_single_local_disk().await;
let root = dir.path();
let obj_dir = root.join("bucket").join("pfx").join("obj");
fs::create_dir_all(&obj_dir).await.expect("object dir should be created");
fs::write(obj_dir.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("object metadata should be written");
let set = make_set_disks_with(vec![Some(disk)]).await;
let purged = set
.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("scan should succeed");
assert!(!purged, "prefix containing an object must not be purged");
assert!(obj_dir.join(STORAGE_FORMAT_FILE).exists(), "object metadata must be preserved");
}
#[tokio::test]
async fn purge_orphan_dir_object_missing_returns_false() {
let (dir, disk) = make_single_local_disk().await;
fs::create_dir_all(dir.path().join("bucket"))
.await
.expect("bucket volume should be created");
let set = make_set_disks_with(vec![Some(disk)]).await;
let purged = set
.purge_orphan_dir_object("bucket", "does-not-exist/")
.await
.expect("scan should succeed");
assert!(!purged, "a missing prefix should report nothing to purge");
}
// Cross-disk safety: if any drive still holds object data under the prefix, refuse
// to purge on every drive so a degraded/healable object is never destroyed.
#[tokio::test]
async fn purge_orphan_dir_object_refuses_when_any_disk_has_data() {
let (dir0, disk0) = make_single_local_disk().await;
let (dir1, disk1) = make_single_local_disk().await;
fs::create_dir_all(dir0.path().join("bucket").join("pfx").join("a"))
.await
.expect("disk0 empty tree should be created");
let obj_dir = dir1.path().join("bucket").join("pfx").join("a");
fs::create_dir_all(&obj_dir)
.await
.expect("disk1 object dir should be created");
fs::write(obj_dir.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("disk1 object metadata should be written");
let set = make_set_disks_with(vec![Some(disk0), Some(disk1)]).await;
let purged = set
.purge_orphan_dir_object("bucket", "pfx/")
.await
.expect("scan should succeed");
assert!(!purged, "must not purge when any disk holds object data");
assert!(
obj_dir.join(STORAGE_FORMAT_FILE).exists(),
"object metadata on the healthy disk must be preserved"
);
assert!(
dir0.path().join("bucket").join("pfx").join("a").exists(),
"empty tree must be left untouched when the purge is aborted"
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_acquire_dist_delete_object_locks_batch_succeeds_with_two_healthy_lockers() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let manager1 = Arc::new(rustfs_lock::GlobalLockManager::new());
let manager2 = Arc::new(rustfs_lock::GlobalLockManager::new());
let client1: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager1.clone()));
let client2: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager2.clone()));
let set_disks = make_test_set_disks(vec![client1, client2]).await;
let batch = rustfs_lock::BatchLockRequest::new(set_disks.locker_owner.as_str())
.with_all_or_nothing(false)
.add_write_lock(ObjectKey::new("bucket", "object-a"))
.add_write_lock(ObjectKey::new("bucket", "object-b"));
let (failed_map, locked_objects, held_lock_ids_by_client) =
set_disks.acquire_dist_delete_object_locks_batch(&batch).await;
assert!(failed_map.is_empty());
assert_eq!(locked_objects.len(), 2);
assert!(locked_objects.contains("object-a"));
assert!(locked_objects.contains("object-b"));
assert_eq!(held_lock_ids_by_client.iter().map(Vec::len).sum::<usize>(), batch.requests.len() * 2);
set_disks
.release_dist_delete_object_locks_batch(held_lock_ids_by_client)
.await;
let local_lock_1 = NamespaceLock::with_local_manager("node-1".to_string(), manager1);
let local_lock_2 = NamespaceLock::with_local_manager("node-2".to_string(), manager2);
let guard_1 = local_lock_1
.get_write_lock(ObjectKey::new("bucket", "object-a"), "owner-b", Duration::from_millis(100))
.await
.expect("released batch lock should free node 1");
let guard_2 = local_lock_2
.get_write_lock(ObjectKey::new("bucket", "object-b"), "owner-b", Duration::from_millis(100))
.await
.expect("released batch lock should free node 2");
drop(guard_1);
drop(guard_2);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_acquire_dist_delete_object_locks_batch_rolls_back_when_quorum_not_reached() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let healthy_client: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager.clone()));
let failing_client: Arc<dyn LockClient> = Arc::new(FailingClient);
let set_disks = make_test_set_disks(vec![healthy_client, failing_client]).await;
let batch = rustfs_lock::BatchLockRequest::new(set_disks.locker_owner.as_str())
.with_all_or_nothing(false)
.add_write_lock(ObjectKey::new("bucket", "object-a"));
let (failed_map, locked_objects, held_lock_ids_by_client) =
set_disks.acquire_dist_delete_object_locks_batch(&batch).await;
assert!(locked_objects.is_empty());
assert!(failed_map.contains_key(&("bucket".to_string(), "object-a".to_string())));
assert_eq!(held_lock_ids_by_client.iter().map(Vec::len).sum::<usize>(), 0);
let local_lock = NamespaceLock::with_local_manager("node-1".to_string(), manager);
let guard = local_lock
.get_write_lock(ObjectKey::new("bucket", "object-a"), "owner-b", Duration::from_millis(100))
.await
.expect("quorum rollback should release the healthy node lock");
drop(guard);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_acquire_dist_delete_object_locks_batch_returns_after_quorum_without_waiting_for_slow_lockers() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let manager_fast_1 = Arc::new(rustfs_lock::GlobalLockManager::new());
let manager_fast_2 = Arc::new(rustfs_lock::GlobalLockManager::new());
let manager_fast_3 = Arc::new(rustfs_lock::GlobalLockManager::new());
let manager_slow = Arc::new(rustfs_lock::GlobalLockManager::new());
let client_fast_1: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager_fast_1));
let client_fast_2: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager_fast_2));
let client_fast_3: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager_fast_3));
let client_slow: Arc<dyn LockClient> = Arc::new(DelayedBatchClient {
inner: Arc::new(LocalClient::with_manager(manager_slow.clone())),
delay: Duration::from_millis(250),
});
let set_disks = make_test_set_disks(vec![client_fast_1, client_fast_2, client_fast_3, client_slow]).await;
let batch = rustfs_lock::BatchLockRequest::new(set_disks.locker_owner.as_str())
.with_all_or_nothing(false)
.add_write_lock(ObjectKey::new("bucket", "object-a"))
.add_write_lock(ObjectKey::new("bucket", "object-b"));
let started = Instant::now();
let (failed_map, locked_objects, held_lock_ids_by_client) =
set_disks.acquire_dist_delete_object_locks_batch(&batch).await;
assert!(
started.elapsed() < Duration::from_millis(150),
"batch distributed delete locks should return once quorum is satisfied"
);
assert!(failed_map.is_empty());
assert_eq!(locked_objects.len(), 2);
set_disks
.release_dist_delete_object_locks_batch(held_lock_ids_by_client)
.await;
tokio::time::sleep(Duration::from_millis(350)).await;
let slow_lock = NamespaceLock::with_local_manager("slow-node".to_string(), manager_slow);
let guard_a = slow_lock
.get_write_lock(ObjectKey::new("bucket", "object-a"), "owner-b", Duration::from_millis(100))
.await
.expect("late successful batch lock should be cleaned up for object-a");
let guard_b = slow_lock
.get_write_lock(ObjectKey::new("bucket", "object-b"), "owner-b", Duration::from_millis(100))
.await
.expect("late successful batch lock should be cleaned up for object-b");
drop(guard_a);
drop(guard_b);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_acquire_dist_delete_object_locks_batch_fails_early_and_cleans_up_late_successes() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let manager_fast = Arc::new(rustfs_lock::GlobalLockManager::new());
let manager_slow = Arc::new(rustfs_lock::GlobalLockManager::new());
let client_fast: Arc<dyn LockClient> = Arc::new(LocalClient::with_manager(manager_fast));
let client_fail_1: Arc<dyn LockClient> = Arc::new(FailingClient);
let client_fail_2: Arc<dyn LockClient> = Arc::new(FailingClient);
let client_slow: Arc<dyn LockClient> = Arc::new(DelayedBatchClient {
inner: Arc::new(LocalClient::with_manager(manager_slow.clone())),
delay: Duration::from_millis(250),
});
let set_disks = make_test_set_disks(vec![client_fast, client_fail_1, client_fail_2, client_slow]).await;
let batch = rustfs_lock::BatchLockRequest::new(set_disks.locker_owner.as_str())
.with_all_or_nothing(false)
.add_write_lock(ObjectKey::new("bucket", "object-a"))
.add_write_lock(ObjectKey::new("bucket", "object-b"));
let started = Instant::now();
let (failed_map, locked_objects, held_lock_ids_by_client) =
set_disks.acquire_dist_delete_object_locks_batch(&batch).await;
assert!(
started.elapsed() < Duration::from_millis(150),
"batch distributed delete locks should fail as soon as quorum becomes impossible"
);
assert!(locked_objects.is_empty());
assert!(failed_map.contains_key(&("bucket".to_string(), "object-a".to_string())));
assert!(failed_map.contains_key(&("bucket".to_string(), "object-b".to_string())));
assert_eq!(held_lock_ids_by_client.iter().map(Vec::len).sum::<usize>(), 0);
tokio::time::sleep(Duration::from_millis(350)).await;
let slow_lock = NamespaceLock::with_local_manager("slow-node".to_string(), manager_slow);
let guard_a = slow_lock
.get_write_lock(ObjectKey::new("bucket", "object-a"), "owner-b", Duration::from_millis(100))
.await
.expect("late successful batch failure cleanup should release object-a");
let guard_b = slow_lock
.get_write_lock(ObjectKey::new("bucket", "object-b"), "owner-b", Duration::from_millis(100))
.await
.expect("late successful batch failure cleanup should release object-b");
drop(guard_a);
drop(guard_b);
}
#[test]
fn test_common_parity() {
// Test common parity calculation
// For parities [2, 2, 2, 3] with n=4, default_parity_count=1:
// - parity=2: read_quorum = 4-2 = 2, occ=3 >= 2, so valid
// - parity=3: read_quorum = 4-3 = 1, occ=1 >= 1, so valid
// - max_occ=3 for parity=2, so returns 2
let parities = vec![2, 2, 2, 3];
assert_eq!(SetDisks::common_parity(&parities, 1), 2);
// For parities [1, 2, 3] with n=3, default_parity_count=2:
// - parity=1: read_quorum = 3-1 = 2, occ=1 < 2, so invalid
// - parity=2: read_quorum = 3-2 = 1, occ=1 >= 1, so valid
// - parity=3: read_quorum = 3-3 = 0, occ=1 >= 0, so valid
// - max_occ=1, both parity=2 and parity=3 have same occurrence
// - HashMap iteration order is not guaranteed, so result could be either 2 or 3
let parities = vec![1, 2, 3];
let result = SetDisks::common_parity(&parities, 2);
assert!(result == 2 || result == 3); // Either 2 or 3 is valid
let empty_parities = vec![];
assert_eq!(SetDisks::common_parity(&empty_parities, 3), -1); // Empty returns -1
let invalid_parities = vec![-1, -1, -1];
assert_eq!(SetDisks::common_parity(&invalid_parities, 2), -1); // all invalid
let single_parity = vec![4];
assert_eq!(SetDisks::common_parity(&single_parity, 1), 4);
// Test with -1 values (ignored)
let parities_with_invalid = vec![-1, 2, 2, -1];
assert_eq!(SetDisks::common_parity(&parities_with_invalid, 1), 2);
}
#[test]
fn test_common_time() {
// Test common time calculation
let now = OffsetDateTime::now_utc();
let later = now + Duration::from_secs(60);
let times = vec![Some(now), Some(now), Some(later)];
assert_eq!(SetDisks::common_time(&times, 2), Some(now));
let times2 = vec![Some(now), Some(later), Some(later)];
assert_eq!(SetDisks::common_time(&times2, 2), Some(later));
let times_with_none = vec![Some(now), None, Some(now)];
assert_eq!(SetDisks::common_time(&times_with_none, 2), Some(now));
let times = vec![None, None, None];
assert_eq!(SetDisks::common_time(&times, 2), None);
let empty_times = vec![];
assert_eq!(SetDisks::common_time(&empty_times, 1), None);
}
#[test]
fn test_common_time_and_occurrence() {
// Test common time with occurrence count
let now = OffsetDateTime::now_utc();
let times = vec![Some(now), Some(now), None];
let (time, count) = SetDisks::common_time_and_occurrence(&times);
assert_eq!(time, Some(now));
assert_eq!(count, 2);
let times = vec![None, None, None];
let (time, count) = SetDisks::common_time_and_occurrence(&times);
assert_eq!(time, None);
assert_eq!(count, 0); // No valid times, so count is 0
}
#[test]
fn test_common_etag() {
// Test common etag calculation
let etags = vec![Some("etag1".to_string()), Some("etag1".to_string()), None];
assert_eq!(SetDisks::common_etag(&etags, 2), Some("etag1".to_string()));
let etags = vec![None, None, None];
assert_eq!(SetDisks::common_etag(&etags, 2), None);
}
#[test]
fn test_common_etags() {
// Test common etags with occurrence count
let etags = vec![Some("etag1".to_string()), Some("etag1".to_string()), None];
let (etag, count) = SetDisks::common_etags(&etags);
assert_eq!(etag, Some("etag1".to_string()));
assert_eq!(count, 2);
}
#[test]
fn test_list_object_modtimes() {
// Test extracting modification times from file info
let now = OffsetDateTime::now_utc();
let file_info = FileInfo {
mod_time: Some(now),
..Default::default()
};
let parts_metadata = vec![file_info];
let errs = vec![None];
let modtimes = SetDisks::list_object_modtimes(&parts_metadata, &errs);
assert_eq!(modtimes.len(), 1);
assert_eq!(modtimes[0], Some(now));
}
#[test]
fn test_list_object_etags() {
// Test extracting etags from file info metadata
let mut metadata = HashMap::new();
metadata.insert("etag".to_string(), "test-etag".to_string());
let file_info = FileInfo {
metadata,
..Default::default()
};
let parts_metadata = vec![file_info];
let errs = vec![None];
let etags = SetDisks::list_object_etags(&parts_metadata, &errs);
assert_eq!(etags.len(), 1);
assert_eq!(etags[0], Some("test-etag".to_string()));
}
fn quorum_test_fileinfo(mod_time: OffsetDateTime, data_dir: Uuid, part_etag: &str, erasure_index: usize) -> FileInfo {
let mut metadata = HashMap::new();
metadata.insert("etag".to_string(), "object-etag".to_string());
FileInfo {
name: "bucket/object".to_string(),
size: 8 * 1024 * 1024,
mod_time: Some(mod_time),
data_dir: Some(data_dir),
metadata,
parts: vec![ObjectPartInfo {
etag: part_etag.to_string(),
number: 1,
size: 8 * 1024 * 1024,
actual_size: 8 * 1024 * 1024,
mod_time: Some(mod_time),
..Default::default()
}],
erasure: ErasureInfo {
data_blocks: 2,
parity_blocks: 2,
block_size: 4 * 1024 * 1024,
index: erasure_index,
distribution: vec![1, 2, 3, 4],
..Default::default()
},
..Default::default()
}
}
fn decoded_quorum_test_fileinfo_with_metadata(
mod_time: OffsetDateTime,
data_dir: Uuid,
part_etag: &str,
erasure_index: usize,
extra_metadata: &[(&str, &str)],
) -> FileInfo {
let mut fi = quorum_test_fileinfo(mod_time, data_dir, part_etag, erasure_index);
for (name, value) in extra_metadata {
fi.metadata.insert((*name).to_string(), (*value).to_string());
}
let mut meta = FileMeta::new();
meta.add_version(fi).expect("test file metadata should accept object version");
let encoded = meta.marshal_msg().expect("test file metadata should marshal");
rustfs_filemeta::get_file_info(
&encoded,
"bucket",
"object",
"",
rustfs_filemeta::FileInfoOpts {
data: false,
include_free_versions: false,
},
)
.expect("test file metadata should decode as file info")
}
#[test]
fn test_find_file_info_in_quorum_uses_part_identity() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 3),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 4),
];
let fi = SetDisks::find_file_info_in_quorum(&metas, &Some(mod_time), &None, 3)
.expect("three matching part identities should reach quorum");
assert_eq!(fi.parts[0].etag, "part-etag-a");
}
#[test]
fn test_find_file_info_in_quorum_rejects_split_part_identity() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 3),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 4),
];
let err = SetDisks::find_file_info_in_quorum(&metas, &Some(mod_time), &None, 3)
.expect_err("split part identities must not reach write quorum");
assert_eq!(err, DiskError::ErasureReadQuorum);
}
#[test]
fn test_latest_fileinfo_selection_quorum_requires_write_quorum_when_full_metadata_is_available() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 3),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-b", 4),
];
let errs = vec![None, None, None, None];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
assert_eq!(quorum, 3);
}
#[test]
fn test_latest_fileinfo_selection_quorum_preserves_read_quorum_for_version_or_degraded_reads() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
FileInfo::default(),
FileInfo::default(),
];
let degraded_errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let clean_errs = vec![None, None, None, None];
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &degraded_errs, 2, 3), 2);
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("version-id", &metas, &clean_errs, 2, 3), 2);
}
#[test]
fn test_latest_fileinfo_selection_quorum_keeps_read_quorum_for_partial_overwrite_with_read_error() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
let new_data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", 1),
quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", 2),
quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", 3),
FileInfo::default(),
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let (online_disks, mod_time, etag) = SetDisks::list_online_disks(&vec![None; metas.len()], &metas, &errs, quorum);
let fi = SetDisks::pick_valid_fileinfo(&metas, mod_time, etag, quorum)
.expect("old metadata should remain readable with read quorum");
assert_eq!(quorum, 2);
assert_eq!(online_disks.len(), metas.len());
assert_eq!(fi.data_dir, Some(old_data_dir));
assert_eq!(fi.parts[0].etag, "part-etag-old");
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
.expect("old metadata should remain selectable with read quorum");
assert_eq!(selected_quorum, 2);
assert_eq!(selected.data_dir, Some(old_data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-old");
assert!(selected.is_latest);
}
#[test]
fn test_latest_fileinfo_selection_rejects_partial_latest_read_quorum_with_read_error() {
let old_mod_time = OffsetDateTime::now_utc();
let new_mod_time = old_mod_time + time::Duration::seconds(1);
let old_data_dir = Uuid::new_v4();
let new_data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", 1),
quorum_test_fileinfo(new_mod_time, new_data_dir, "part-etag-new", 2),
quorum_test_fileinfo(old_mod_time, old_data_dir, "part-etag-old", 3),
FileInfo::default(),
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
}
#[test]
fn test_latest_fileinfo_selection_preserves_degraded_read_quorum_without_competing_latest() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-old", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-old", 2),
FileInfo::default(),
FileInfo::default(),
];
let errs = vec![None, None, Some(DiskError::DiskNotFound), Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
.expect("read quorum should remain enough when no competing latest is visible");
assert_eq!(selected_quorum, 2);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag-old");
}
#[test]
fn test_latest_fileinfo_selection_ignores_derived_version_stack_drift() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut latest_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
latest_meta.is_latest = true;
latest_meta.num_versions = 1;
let mut stale_stack_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
stale_stack_meta.is_latest = false;
stale_stack_meta.successor_mod_time = Some(mod_time + time::Duration::seconds(1));
stale_stack_meta.num_versions = 2;
let mut newer_stack_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
newer_stack_meta.is_latest = false;
newer_stack_meta.successor_mod_time = Some(mod_time + time::Duration::seconds(2));
newer_stack_meta.num_versions = 3;
let metas = vec![latest_meta, stale_stack_meta, newer_stack_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
.expect("same object version should stay readable despite derived version stack drift");
assert_eq!(selected_quorum, 3);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag");
assert_eq!(selected.mod_time, Some(mod_time));
}
#[test]
fn test_latest_fileinfo_selection_uses_successor_mod_time_quorum_for_latest_flag() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut stale_stack_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
stale_stack_meta.is_latest = false;
stale_stack_meta.successor_mod_time = Some(mod_time + time::Duration::seconds(1));
stale_stack_meta.num_versions = 2;
let mut latest_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
latest_meta_a.is_latest = true;
latest_meta_a.num_versions = 1;
let mut latest_meta_b = latest_meta_a.clone();
latest_meta_b.erasure.index = 3;
let metas = vec![stale_stack_meta, latest_meta_a, latest_meta_b];
let selected = SetDisks::find_file_info_in_quorum(&metas, &Some(mod_time), &None, 2)
.expect("latest flag should be derived from successor mod time quorum");
assert!(selected.is_latest);
assert_eq!(selected.successor_mod_time, None);
assert_eq!(selected.num_versions, 1);
assert_eq!(selected.mod_time, Some(mod_time));
}
#[test]
fn test_latest_fileinfo_selection_ignores_replication_state_drift() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let replication_status_key = format!(
"{}{}",
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
rustfs_utils::http::SUFFIX_REPLICATION_STATUS
);
let replication_timestamp_key = format!(
"{}{}",
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
rustfs_utils::http::SUFFIX_REPLICATION_TIMESTAMP
);
let replication_reset_key = format!(
"{}{}target-a",
rustfs_utils::http::RUSTFS_INTERNAL_PREFIX,
rustfs_utils::http::SUFFIX_REPLICATION_RESET_ARN_PREFIX
);
let meta_a = decoded_quorum_test_fileinfo_with_metadata(
mod_time,
data_dir,
"part-etag",
1,
&[
(&replication_status_key, "target-a=COMPLETED;"),
(&replication_timestamp_key, "2024-01-01T00:00:00Z"),
(&replication_reset_key, "COMPLETED"),
],
);
let meta_b = decoded_quorum_test_fileinfo_with_metadata(
mod_time,
data_dir,
"part-etag",
2,
&[
(&replication_status_key, "target-a=PENDING;"),
(&replication_timestamp_key, "2024-01-01T00:00:01Z"),
(&replication_reset_key, "PENDING"),
],
);
let meta_c = decoded_quorum_test_fileinfo_with_metadata(
mod_time,
data_dir,
"part-etag",
3,
&[
(&replication_status_key, "target-a=FAILED;"),
(&replication_timestamp_key, "2024-01-01T00:00:02Z"),
(&replication_reset_key, "FAILED"),
],
);
assert!(meta_a.replication_state_internal.is_some());
assert_eq!(
meta_a
.metadata
.get(rustfs_utils::http::AMZ_BUCKET_REPLICATION_STATUS)
.map(String::as_str),
Some("COMPLETED")
);
let metas = vec![meta_a, meta_b, meta_c, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let (_, selected, selected_quorum) = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3)
.expect("replication status drift should not split readable object identity");
assert_eq!(selected_quorum, 3);
assert_eq!(selected.data_dir, Some(data_dir));
assert_eq!(selected.parts[0].etag, "part-etag");
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_metadata_split_without_write_quorum() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let mut old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
old_meta_a.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
old_meta_b.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
partial_meta
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
assert_eq!(quorum, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_partial_metadata_read_quorum() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let mut old_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let mut partial_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
old_meta.metadata.insert("x-amz-meta-color".to_string(), "blue".to_string());
partial_meta_a
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
partial_meta_b
.metadata
.insert("x-amz-meta-color".to_string(), "red".to_string());
let metas = vec![old_meta, partial_meta_a, partial_meta_b, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
}
#[test]
fn test_latest_fileinfo_selection_rejects_same_modtime_transition_split_without_write_quorum() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let old_meta_a = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 1);
let old_meta_b = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 2);
let mut partial_meta = quorum_test_fileinfo(mod_time, data_dir, "part-etag", 3);
partial_meta.transition_status = TRANSITION_COMPLETE.to_string();
partial_meta.transition_tier = "WARM".to_string();
partial_meta.transitioned_objname = "remote/object".to_string();
partial_meta.transition_version_id = Some(Uuid::new_v4());
let metas = vec![old_meta_a, old_meta_b, partial_meta, FileInfo::default()];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
let quorum = SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3);
let result = SetDisks::select_valid_fileinfo(&vec![None; metas.len()], &metas, &errs, "", 2, 3);
assert_eq!(quorum, 2);
assert!(matches!(result, Err(DiskError::ErasureReadQuorum)));
}
#[test]
fn test_latest_fileinfo_selection_quorum_uses_write_quorum_for_degraded_committed_identity() {
let mod_time = OffsetDateTime::now_utc();
let data_dir = Uuid::new_v4();
let metas = vec![
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 1),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 2),
quorum_test_fileinfo(mod_time, data_dir, "part-etag-a", 3),
FileInfo::default(),
];
let errs = vec![None, None, None, Some(DiskError::DiskNotFound)];
assert_eq!(SetDisks::latest_fileinfo_selection_quorum("", &metas, &errs, 2, 3), 3);
}
#[test]
fn test_list_object_parities() {
// Test extracting parity counts from file info
let file_info1 = FileInfo {
erasure: ErasureInfo {
data_blocks: 4,
parity_blocks: 2,
index: 1, // Must be > 0 for is_valid() to return true
distribution: vec![1, 2, 3, 4, 5, 6], // Must match data_blocks + parity_blocks
..Default::default()
},
size: 100, // Non-zero size
deleted: false,
..Default::default()
};
let file_info2 = FileInfo {
erasure: ErasureInfo {
data_blocks: 6,
parity_blocks: 3,
index: 1, // Must be > 0 for is_valid() to return true
distribution: vec![1, 2, 3, 4, 5, 6, 7, 8, 9], // Must match data_blocks + parity_blocks
..Default::default()
},
size: 200, // Non-zero size
deleted: false,
..Default::default()
};
let file_info3 = FileInfo {
erasure: ErasureInfo {
data_blocks: 2,
parity_blocks: 1,
index: 1, // Must be > 0 for is_valid() to return true
distribution: vec![1, 2, 3], // Must match data_blocks + parity_blocks
..Default::default()
},
size: 0, // Zero size - function returns half of total shards
deleted: false,
..Default::default()
};
let parts_metadata = vec![file_info1, file_info2, file_info3];
let errs = vec![None, None, None];
let parities = SetDisks::list_object_parities(&parts_metadata, &errs);
assert_eq!(parities.len(), 3);
assert_eq!(parities[0], 2); // parity_blocks from first file
assert_eq!(parities[1], 3); // parity_blocks from second file
assert_eq!(parities[2], 1); // half of total shards (3/2 = 1) for zero size file
}
#[test]
fn test_conv_part_err_to_int() {
// Test error conversion to integer codes
assert_eq!(conv_part_err_to_int(&None), CHECK_PART_SUCCESS);
let disk_err = DiskError::FileNotFound;
assert_eq!(conv_part_err_to_int(&Some(disk_err)), CHECK_PART_FILE_NOT_FOUND);
let other_err = DiskError::other("other error");
assert_eq!(conv_part_err_to_int(&Some(other_err)), CHECK_PART_UNKNOWN); // Other errors should return UNKNOWN, not SUCCESS
}
#[test]
fn test_has_part_err() {
// Test checking for part errors
let no_errors = vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS];
assert!(!has_part_err(&no_errors));
let with_errors = vec![CHECK_PART_SUCCESS, CHECK_PART_FILE_NOT_FOUND];
assert!(has_part_err(&with_errors));
let unknown_errors = vec![CHECK_PART_UNKNOWN, CHECK_PART_SUCCESS];
assert!(has_part_err(&unknown_errors));
}
#[test]
fn test_populate_data_errs_by_disk_uses_disk_index_not_error_code() {
let mut data_errs_by_disk = HashMap::from([
(0, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
(1, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
(2, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
]);
let data_errs_by_part = HashMap::from([
(0, vec![CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_SUCCESS]),
(1, vec![CHECK_PART_SUCCESS, CHECK_PART_FILE_CORRUPT, CHECK_PART_SUCCESS]),
]);
populate_data_errs_by_disk(&mut data_errs_by_disk, &data_errs_by_part);
assert_eq!(data_errs_by_disk.get(&0).unwrap(), &vec![CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS]);
assert_eq!(data_errs_by_disk.get(&1).unwrap(), &vec![CHECK_PART_SUCCESS, CHECK_PART_FILE_CORRUPT]);
assert_eq!(data_errs_by_disk.get(&2).unwrap(), &vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS]);
let mut data_errs_by_disk = HashMap::from([
(0, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
(1, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
(2, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
(3, vec![CHECK_PART_UNKNOWN, CHECK_PART_UNKNOWN]),
]);
let data_errs_by_part = HashMap::from([
(
0,
vec![
CHECK_PART_FILE_NOT_FOUND,
CHECK_PART_SUCCESS,
CHECK_PART_SUCCESS,
CHECK_PART_SUCCESS,
],
),
(
1,
vec![
CHECK_PART_FILE_CORRUPT,
CHECK_PART_SUCCESS,
CHECK_PART_SUCCESS,
CHECK_PART_SUCCESS,
],
),
]);
populate_data_errs_by_disk(&mut data_errs_by_disk, &data_errs_by_part);
assert_eq!(
data_errs_by_disk.get(&0).unwrap(),
&vec![CHECK_PART_FILE_NOT_FOUND, CHECK_PART_FILE_CORRUPT]
);
assert_eq!(data_errs_by_disk.get(&1).unwrap(), &vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS]);
assert_eq!(data_errs_by_disk.get(&2).unwrap(), &vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS]);
assert_eq!(data_errs_by_disk.get(&3).unwrap(), &vec![CHECK_PART_SUCCESS, CHECK_PART_SUCCESS]);
}
#[test]
fn test_should_heal_object_on_disk() {
// Test healing decision logic
let meta = FileInfo::default();
let latest_meta = FileInfo::default();
// Test with file not found error
let err = Some(DiskError::FileNotFound);
let (should_heal, _, _) = should_heal_object_on_disk(&err, &[], &meta, &latest_meta);
assert!(should_heal);
// Test with no error and no part errors
let (should_heal, _, _) = should_heal_object_on_disk(&None, &[CHECK_PART_SUCCESS], &meta, &latest_meta);
assert!(!should_heal);
// Test with part corruption
let (should_heal, _, _) = should_heal_object_on_disk(&None, &[CHECK_PART_FILE_CORRUPT], &meta, &latest_meta);
assert!(should_heal);
}
#[tokio::test]
async fn test_get_disks_info_preserves_runtime_state_for_suspect_and_offline_disks() {
let format = FormatV3::new(1, 3);
let mut temp_dirs = Vec::new();
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..3 {
let (dir, endpoint, disk) = make_formatted_local_disk_for_info_test(disk_idx, &format).await;
temp_dirs.push(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
disks[1]
.as_ref()
.expect("disk 1 should exist")
.force_runtime_state_for_test(RuntimeDriveHealthState::Suspect);
disks[2]
.as_ref()
.expect("disk 2 should exist")
.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
let info = get_disks_info(&disks, &endpoints).await;
assert_eq!(info.len(), 3);
assert_eq!(info[0].state, "ok");
assert_eq!(info[0].runtime_state.as_deref(), Some("online"));
assert!(!info[0].drive_path.is_empty(), "online disk should keep immediate disk_info probe");
assert_eq!(info[1].state, "ok");
assert_eq!(info[1].runtime_state.as_deref(), Some("suspect"));
assert!(!info[1].drive_path.is_empty(), "suspect disk should still probe for fresher disk info");
assert_eq!(info[2].state, "offline");
assert_eq!(info[2].runtime_state.as_deref(), Some("offline"));
assert!(info[2].drive_path.is_empty(), "offline disk should use runtime snapshot fallback");
}
#[tokio::test]
async fn test_get_disks_info_uses_capacity_snapshot_for_offline_disk() {
let format = FormatV3::new(1, 1);
let (temp_dir, endpoint, disk) = make_formatted_local_disk_for_info_test(0, &format).await;
disk.record_capacity_probe(100, 40, 60);
disk.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
let info = get_disks_info(&[Some(disk)], &[endpoint]).await;
assert_eq!(info.len(), 1);
assert_eq!(info[0].state, "offline");
assert_eq!(info[0].runtime_state.as_deref(), Some("offline"));
assert_eq!(info[0].capacity_observation_source.as_deref(), Some("snapshot"));
assert!(info[0].capacity_observation_age_seconds.unwrap_or(u64::MAX) <= 60);
assert_eq!(info[0].total_space, 100);
assert_eq!(info[0].used_space, 40);
assert_eq!(info[0].available_space, 60);
assert_eq!(info[0].utilization, 40.0);
drop(temp_dir);
}
#[tokio::test]
async fn list_path_returns_read_quorum_when_runtime_candidates_are_empty() {
let disk_count = 4;
let format = FormatV3::new(1, disk_count);
let mut temp_dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
let mut disks = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count {
let (dir, endpoint, disk) = make_formatted_local_disk_for_info_test(disk_idx, &format).await;
temp_dirs.push(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
disk_count,
disk_count / 2,
0,
0,
endpoints,
format,
Vec::new(),
)
.await;
for disk in set_disks.get_disks_internal().await.iter().flatten() {
disk.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
}
let (tx, _rx) = mpsc::channel(1);
let err = set_disks
.list_path(
CancellationToken::new(),
ListPathOptions {
bucket: "bucket".to_string(),
recursive: true,
..Default::default()
},
tx,
)
.await
.expect_err("empty runtime candidate set should fail before list_path_raw");
assert_eq!(err, StorageError::ErasureReadQuorum);
drop(temp_dirs);
}
#[tokio::test]
async fn load_file_info_versions_exact_returns_none_for_explicit_not_found() {
let format = FormatV3::new(1, 1);
let (temp_dir, endpoint, disk) = make_formatted_local_disk_for_info_test(0, &format).await;
let bucket = "bucket";
disk.make_volume(bucket).await.expect("bucket should be created");
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(vec![Some(disk)])),
1,
0,
0,
0,
vec![endpoint],
format,
Vec::new(),
)
.await;
let versions = set_disks
.load_file_info_versions_exact(bucket, "missing-object")
.await
.expect("explicit object not found should be accepted");
assert!(versions.is_none());
drop(temp_dir);
}
#[tokio::test]
async fn load_file_info_versions_exact_rejects_corrupt_metadata() {
let format = FormatV3::new(1, 1);
let (temp_dir, endpoint, disk) = make_formatted_local_disk_for_info_test(0, &format).await;
let bucket = "bucket";
let object = "object.txt";
disk.make_volume(bucket).await.expect("bucket should be created");
let metadata_path = format!("{object}/{STORAGE_FORMAT_FILE}");
disk.write_all(bucket, &metadata_path, Bytes::from_static(b"not-xl-meta"))
.await
.expect("corrupt metadata file should be written");
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(vec![Some(disk)])),
1,
0,
0,
0,
vec![endpoint],
format,
Vec::new(),
)
.await;
let err = set_disks
.load_file_info_versions_exact(bucket, object)
.await
.expect_err("corrupt exact metadata must fail closed");
assert!(!is_err_object_not_found(&err), "corrupt metadata must not be treated as not found: {err}");
drop(temp_dir);
}
#[tokio::test]
async fn list_path_still_uses_disk_after_prior_walk_timeout() {
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
struct PendingWriter;
impl AsyncWrite for PendingWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
Poll::Pending
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
let format = FormatV3::new(1, 1);
let (temp_dir, endpoint, disk) = make_formatted_local_disk_for_info_test(0, &format).await;
let bucket = "bucket";
let object = "obj";
disk.make_volume(bucket).await.expect("bucket should be created");
let metadata_path = format!("{object}/{STORAGE_FORMAT_FILE}");
disk.write_all(bucket, &metadata_path, Bytes::from_static(b"not-an-xl-meta"))
.await
.expect("metadata file should be created");
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(vec![Some(disk.clone())])),
1,
0,
0,
0,
vec![endpoint],
format,
Vec::new(),
)
.await;
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, Some("1")),
(rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, Some("1")),
],
async {
let mut writer = PendingWriter;
let walk_err = disk
.walk_dir(
WalkDirOptions {
bucket: bucket.to_string(),
recursive: true,
..Default::default()
},
&mut writer,
)
.await
.expect_err("walk_dir should time out");
assert_eq!(walk_err, DiskError::Timeout);
assert_eq!(disk.runtime_state(), RuntimeDriveHealthState::Online);
let (tx, mut rx) = mpsc::channel::<MetaCacheEntry>(4);
set_disks
.list_path(
CancellationToken::new(),
ListPathOptions {
bucket: bucket.to_string(),
recursive: true,
..Default::default()
},
tx,
)
.await
.expect("list_path should still succeed after prior walk timeout");
let entry = rx.recv().await.expect("listing should yield the object entry");
assert_eq!(entry.name, object);
assert_eq!(disk.runtime_state(), RuntimeDriveHealthState::Online);
},
)
.await;
drop(temp_dir);
}
#[tokio::test]
async fn list_path_system_prefix_survives_prior_walk_timeout() {
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
struct PendingWriter;
impl AsyncWrite for PendingWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &[u8]) -> Poll<std::io::Result<usize>> {
Poll::Pending
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
let format = FormatV3::new(1, 1);
let (temp_dir, endpoint, disk) = make_formatted_local_disk_for_info_test(0, &format).await;
let object = "config/iam/sts/test/identity.json";
let metadata_path = format!("{object}/{STORAGE_FORMAT_FILE}");
disk.write_all(RUSTFS_META_BUCKET, &metadata_path, Bytes::from_static(b"not-an-xl-meta"))
.await
.expect("system path metadata file should be created");
let set_disks = SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(vec![Some(disk.clone())])),
1,
0,
0,
0,
vec![endpoint],
format,
Vec::new(),
)
.await;
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, Some("1")),
(rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, Some("1")),
],
async {
let mut writer = PendingWriter;
let walk_err = disk
.walk_dir(
WalkDirOptions {
bucket: RUSTFS_META_BUCKET.to_string(),
base_dir: "config/iam/".to_string(),
recursive: true,
..Default::default()
},
&mut writer,
)
.await
.expect_err("walk_dir should time out");
assert_eq!(walk_err, DiskError::Timeout);
assert_eq!(disk.runtime_state(), RuntimeDriveHealthState::Online);
let (tx, mut rx) = mpsc::channel::<MetaCacheEntry>(4);
set_disks
.list_path(
CancellationToken::new(),
ListPathOptions {
bucket: RUSTFS_META_BUCKET.to_string(),
base_dir: "config/iam/".to_string(),
recursive: true,
..Default::default()
},
tx,
)
.await
.expect("system prefix list_path should still succeed after prior walk timeout");
let entry = rx.recv().await.expect("listing should yield the system-path entry");
assert_eq!(entry.name, "config/iam/sts/");
assert!(
entry.is_dir(),
"system prefix listing should still yield a directory entry after timeout recovery"
);
assert_eq!(disk.runtime_state(), RuntimeDriveHealthState::Online);
},
)
.await;
drop(temp_dir);
}
#[test]
fn test_dangling_meta_errs_count() {
// Test counting dangling metadata errors
let errs = vec![None, Some(DiskError::FileNotFound), None];
let (not_found_count, non_actionable_count) = dangling_meta_errs_count(&errs);
assert_eq!(not_found_count, 1); // One FileNotFound error
assert_eq!(non_actionable_count, 0); // No other errors
}
#[test]
fn test_dangling_part_errs_count() {
// Test counting dangling part errors
let results = vec![CHECK_PART_SUCCESS, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS];
let (not_found_count, non_actionable_count) = dangling_part_errs_count(&results);
assert_eq!(not_found_count, 1); // One FILE_NOT_FOUND error
assert_eq!(non_actionable_count, 0); // No other errors
}
#[test]
fn test_is_object_dir_dangling() {
// Test object directory dangling detection
let errs = vec![Some(DiskError::FileNotFound), Some(DiskError::FileNotFound), None];
assert!(is_object_dir_dangling(&errs));
let errs2 = vec![None, None, None];
assert!(!is_object_dir_dangling(&errs2));
let errs3 = vec![Some(DiskError::FileCorrupt), Some(DiskError::FileNotFound)];
assert!(!is_object_dir_dangling(&errs3)); // Mixed errors, not all not found
}
#[test]
fn test_join_errs() {
// Test joining error messages
let errs = vec![None, Some(DiskError::other("error1")), Some(DiskError::other("error2"))];
let joined = join_errs(&errs);
assert!(joined.contains("<nil>"));
assert!(joined.contains("io error")); // DiskError::other is rendered as "io error"
// Test with different error types
let errs2 = vec![None, Some(DiskError::FileNotFound), Some(DiskError::FileCorrupt)];
let joined2 = join_errs(&errs2);
assert!(joined2.contains("<nil>"));
assert!(joined2.contains("file not found"));
assert!(joined2.contains("file is corrupted"));
}
#[test]
fn test_reduce_common_data_dir() {
// Test reducing common data directory
use uuid::Uuid;
let uuid1 = Uuid::new_v4();
let uuid2 = Uuid::new_v4();
let data_dirs = vec![Some(uuid1), Some(uuid1), Some(uuid2)];
let result = SetDisks::reduce_common_data_dir(&data_dirs, 2);
assert_eq!(result, Some(uuid1)); // uuid1 appears twice, meets quorum
let data_dirs = vec![Some(uuid1), Some(uuid2), None];
let result = SetDisks::reduce_common_data_dir(&data_dirs, 2);
assert_eq!(result, None); // No UUID meets quorum of 2
let data_dirs = vec![Some(uuid1), Some(uuid1), None, None];
let result = SetDisks::reduce_common_data_dir(&data_dirs, 2);
assert_eq!(result, Some(uuid1)); // Ignore None votes; uuid1 should still meet quorum
}
#[test]
fn test_object_quorum_from_meta_returns_not_found_when_all_metadata_is_missing() {
let errs = vec![
Some(DiskError::FileNotFound),
Some(DiskError::VolumeNotFound),
Some(DiskError::DiskNotFound),
Some(DiskError::FileNotFound),
];
let err = SetDisks::object_quorum_from_meta(&vec![FileInfo::default(); errs.len()], &errs, 2)
.expect_err("missing metadata should map to FileNotFound");
assert_eq!(err, DiskError::FileNotFound);
}
#[test]
fn test_object_quorum_from_meta_preserves_read_quorum_for_mixed_failures() {
let errs = vec![
Some(DiskError::FileNotFound),
Some(DiskError::VolumeNotFound),
Some(DiskError::FileCorrupt),
Some(DiskError::DiskNotFound),
];
let err = SetDisks::object_quorum_from_meta(&vec![FileInfo::default(); errs.len()], &errs, 2)
.expect_err("mixed metadata failures should keep quorum semantics");
assert_eq!(err, DiskError::ErasureReadQuorum);
}
#[test]
fn test_shuffle_parts_metadata() {
// Test metadata shuffling
let metadata = vec![
FileInfo {
name: "file1".to_string(),
..Default::default()
},
FileInfo {
name: "file2".to_string(),
..Default::default()
},
FileInfo {
name: "file3".to_string(),
..Default::default()
},
];
// Distribution uses 1-based indexing
let distribution = vec![3, 1, 2]; // 1-based shuffle order
let result = SetDisks::shuffle_parts_metadata(&metadata, &distribution);
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "file2"); // distribution[1] = 1, so metadata[1] goes to index 0
assert_eq!(result[1].name, "file3"); // distribution[2] = 2, so metadata[2] goes to index 1
assert_eq!(result[2].name, "file1"); // distribution[0] = 3, so metadata[0] goes to index 2
// Test with empty distribution
let empty_distribution = vec![];
let result2 = SetDisks::shuffle_parts_metadata(&metadata, &empty_distribution);
assert_eq!(result2.len(), 3);
assert_eq!(result2[0].name, "file1"); // Should return original order
}
#[test]
fn test_shuffle_disks() {
// Test disk shuffling
let disks = vec![None, None, None]; // Mock disks
let distribution = vec![3, 1, 2]; // 1-based indexing
let result = SetDisks::shuffle_disks(&disks, &distribution);
assert_eq!(result.len(), 3);
// All disks are None, so result should be all None
assert!(result.iter().all(|d| d.is_none()));
// Test with empty distribution
let empty_distribution = vec![];
let result2 = SetDisks::shuffle_disks(&disks, &empty_distribution);
assert_eq!(result2.len(), 3);
assert!(result2.iter().all(|d| d.is_none()));
}
#[test]
fn test_etag_matches() {
assert!(e_tag_matches("abc", "abc"));
assert!(e_tag_matches("\"abc\"", "abc"));
assert!(e_tag_matches("\"abc\"", "*"));
}
#[test]
fn test_build_tiered_decommission_file_info_preserves_transition_metadata() {
let version_id = Uuid::new_v4();
let transition_version_id = Uuid::new_v4();
let original = FileInfo {
version_id: Some(version_id),
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/object".to_string(),
transition_tier: "WARM-TIER".to_string(),
transition_version_id: Some(transition_version_id),
erasure: FileInfo::new("old-bucket/old-object", 8, 8).erasure,
..Default::default()
};
let (updated, write_quorum) = build_tiered_decommission_file_info("bucket", "object", &original, 16, 4, None);
assert_eq!(updated.version_id, original.version_id);
assert_eq!(updated.transition_status, original.transition_status);
assert_eq!(updated.transitioned_objname, original.transitioned_objname);
assert_eq!(updated.transition_tier, original.transition_tier);
assert_eq!(updated.transition_version_id, original.transition_version_id);
assert_eq!(updated.erasure.data_blocks, 12);
assert_eq!(updated.erasure.parity_blocks, 4);
assert_eq!(write_quorum, 12);
assert_ne!(updated.erasure.distribution, original.erasure.distribution);
}
#[test]
fn test_resolve_tiered_decommission_write_quorum_result_allows_successful_quorum() {
let errs = vec![None, None, Some(DiskError::DiskNotFound), None];
let result = resolve_tiered_decommission_write_quorum_result(&errs, 3, "bucket", "object");
assert!(result.is_ok());
}
#[test]
fn test_resolve_tiered_decommission_write_quorum_result_wraps_object_context() {
let errs = vec![
Some(DiskError::DiskNotFound),
Some(DiskError::DiskNotFound),
Some(DiskError::DiskNotFound),
Some(DiskError::DiskNotFound),
];
let err = resolve_tiered_decommission_write_quorum_result(&errs, 3, "bucket", "object").expect_err("expected error");
let rendered = err.to_string();
assert!(rendered.contains("bucket"), "{rendered}");
assert!(rendered.contains("object"), "{rendered}");
}
#[test]
fn test_check_object_lock_retention_update_blocks_compliance_shorten() {
let now = OffsetDateTime::now_utc();
let existing_until = now + Duration::from_secs(60 * 60 * 24 * 60);
let requested_until = now + Duration::from_secs(60 * 60 * 24);
let mut user_defined = HashMap::new();
user_defined.insert(
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string(),
);
user_defined.insert(
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
existing_until.format(&time::format_description::well_known::Rfc3339).unwrap(),
);
let obj_info = ObjectInfo {
user_defined: Arc::new(user_defined),
..Default::default()
};
let opts = ObjectOptions {
object_lock_retention: Some(crate::storage_api_contracts::object::ObjectLockRetentionOptions {
mode: Some(s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string()),
retain_until: Some(requested_until),
bypass_governance: true,
}),
..Default::default()
};
let err = check_object_lock_retention_update("bucket", "object", &obj_info, &opts)
.expect_err("COMPLIANCE shortening must be blocked");
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)));
}
#[test]
fn test_check_object_lock_retention_update_allows_governance_shorten_with_bypass() {
let now = OffsetDateTime::now_utc();
let existing_until = now + Duration::from_secs(60 * 60 * 24 * 60);
let requested_until = now + Duration::from_secs(60 * 60 * 24);
let mut user_defined = HashMap::new();
user_defined.insert(
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
s3s::dto::ObjectLockRetentionMode::GOVERNANCE.to_string(),
);
user_defined.insert(
X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(),
existing_until.format(&time::format_description::well_known::Rfc3339).unwrap(),
);
let obj_info = ObjectInfo {
user_defined: Arc::new(user_defined),
..Default::default()
};
let opts = ObjectOptions {
object_lock_retention: Some(crate::storage_api_contracts::object::ObjectLockRetentionOptions {
mode: Some(s3s::dto::ObjectLockRetentionMode::GOVERNANCE.to_string()),
retain_until: Some(requested_until),
bypass_governance: true,
}),
..Default::default()
};
check_object_lock_retention_update("bucket", "object", &obj_info, &opts)
.expect("GOVERNANCE shortening with bypass should remain allowed");
}
#[test]
fn test_should_persist_encryption_original_size_rejects_plain_metadata() {
let metadata = HashMap::from([("content-type".to_string(), "application/octet-stream".to_string())]);
assert!(!should_persist_encryption_original_size(&metadata));
}
#[test]
fn test_should_persist_encryption_original_size_accepts_sse_c_metadata() {
let metadata = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
assert!(should_persist_encryption_original_size(&metadata));
}
#[test]
fn test_should_prevent_write() {
let oi = ObjectInfo {
etag: Some("abc".to_string()),
..Default::default()
};
let if_none_match = Some("abc".to_string());
let if_match = None;
assert!(should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = Some("*".to_string());
let if_match = None;
assert!(should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = None;
let if_match = Some("def".to_string());
assert!(should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = None;
let if_match = Some("*".to_string());
assert!(!should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = Some("def".to_string());
let if_match = None;
assert!(!should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = Some("def".to_string());
let if_match = Some("*".to_string());
assert!(!should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = Some("def".to_string());
let if_match = Some("\"abc\"".to_string());
assert!(!should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = Some("*".to_string());
let if_match = Some("\"abc\"".to_string());
assert!(should_prevent_write(&oi, if_none_match, if_match));
let oi = ObjectInfo {
etag: None,
..Default::default()
};
let if_none_match = Some("*".to_string());
let if_match = Some("\"abc\"".to_string());
assert!(should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = None;
let if_match = None;
assert!(!should_prevent_write(&oi, if_none_match, if_match));
let if_none_match = Some(String::new());
let if_match = Some(" ".to_string());
assert!(!should_prevent_write(&oi, if_none_match, if_match));
}
#[test]
fn test_is_valid_storage_class() {
// Test valid storage classes
assert!(is_valid_storage_class(storageclass::STANDARD));
assert!(is_valid_storage_class(storageclass::RRS));
assert!(is_valid_storage_class(storageclass::DEEP_ARCHIVE));
assert!(is_valid_storage_class(storageclass::EXPRESS_ONEZONE));
assert!(is_valid_storage_class(storageclass::GLACIER));
assert!(is_valid_storage_class(storageclass::GLACIER_IR));
assert!(is_valid_storage_class(storageclass::INTELLIGENT_TIERING));
assert!(is_valid_storage_class(storageclass::ONEZONE_IA));
assert!(is_valid_storage_class(storageclass::OUTPOSTS));
assert!(is_valid_storage_class(storageclass::SNOW));
assert!(is_valid_storage_class(storageclass::STANDARD_IA));
// Test invalid storage classes
assert!(!is_valid_storage_class("INVALID"));
assert!(!is_valid_storage_class(""));
assert!(!is_valid_storage_class("standard")); // lowercase
}
#[test]
fn complete_part_checksum_accepts_missing_value_and_uses_base_type() {
let missing_checksum_part = CompletePart::default();
assert_eq!(
complete_part_checksum(&missing_checksum_part, rustfs_rio::ChecksumType::CRC64_NVME),
Some(None)
);
let full_object_crc32 =
rustfs_rio::ChecksumType(rustfs_rio::ChecksumType::CRC32.0 | rustfs_rio::ChecksumType::FULL_OBJECT.0);
let part = CompletePart {
checksum_crc32: Some("AAAAAA==".to_string()),
..Default::default()
};
assert_eq!(complete_part_checksum(&part, full_object_crc32), Some(Some("AAAAAA==".to_string())));
}
fn direct_memory_test_metadata(size: i64) -> (ObjectInfo, FileInfo, ObjectOptions) {
let part_size = usize::try_from(size).expect("test size should fit usize");
let part = ObjectPartInfo {
number: 1,
size: part_size,
actual_size: size,
..Default::default()
};
let object_info = ObjectInfo {
size,
actual_size: size,
parts: Arc::new(vec![part]),
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
..Default::default()
};
let mut fi = FileInfo::new("bucket/object", 1, 0);
fi.size = size;
fi.add_object_part(1, String::new(), part_size, None, size, None, None);
(object_info, fi, ObjectOptions::default())
}
#[test]
fn small_object_direct_memory_eligibility_is_conservative() {
let (object_info, fi, opts) = direct_memory_test_metadata(1024);
assert!(is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&object_info,
&fi,
&opts,
128 * 1024
));
assert!(!is_get_small_object_direct_memory_eligible_with_threshold(
&Some(HTTPRangeSpec {
start: 0,
end: 10,
is_suffix_length: false,
}),
&object_info,
&fi,
&opts,
128 * 1024
));
let mut part_opts = opts.clone();
part_opts.part_number = Some(1);
assert!(!is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&object_info,
&fi,
&part_opts,
128 * 1024
));
let mut versioned_opts = opts.clone();
versioned_opts.versioned = true;
assert!(!is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&object_info,
&fi,
&versioned_opts,
128 * 1024
));
let mut remote = object_info;
remote.transitioned_object.status = TRANSITION_COMPLETE.to_string();
remote.transitioned_object.tier = "remote-tier".to_string();
assert!(!is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&remote,
&fi,
&opts,
128 * 1024
));
}
#[test]
fn small_object_direct_memory_eligibility_respects_threshold_and_shape() {
let (object_info, fi, opts) = direct_memory_test_metadata(128 * 1024);
assert!(is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&object_info,
&fi,
&opts,
128 * 1024
));
assert!(!is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&object_info,
&fi,
&opts,
(128 * 1024) - 1
));
let mut multipart = object_info;
multipart.parts = Arc::new(vec![ObjectPartInfo::default(), ObjectPartInfo::default()]);
assert!(!is_get_small_object_direct_memory_eligible_with_threshold(
&None,
&multipart,
&fi,
&opts,
128 * 1024
));
}
async fn inline_bitrot_files_for_payload(payload: &[u8]) -> (coding::Erasure, Vec<FileInfo>, usize, HashAlgorithm) {
let erasure = coding::Erasure::new(4, 2, 1024 * 1024);
let read_length = erasure.shard_file_offset(0, payload.len(), payload.len());
let checksum_algo = HashAlgorithm::HighwayHash256S;
let shards = erasure.encode_data(payload).expect("payload should encode");
let mut files = Vec::with_capacity(shards.len());
for shard in shards {
let mut writer = coding::BitrotWriterWrapper::new(
coding::CustomWriter::new_inline_buffer(),
erasure.shard_size(),
checksum_algo.clone(),
);
writer.write(&shard).await.expect("inline shard should write");
writer.shutdown().await.expect("inline writer should shutdown");
let data = writer.into_inline_data().expect("inline data should be retained");
let mut file = FileInfo::new("bucket/object", erasure.data_shards, erasure.parity_shards);
file.erasure.index = files.len() + 1;
file.data = Some(Bytes::from(data));
files.push(file);
}
(erasure, files, read_length, checksum_algo)
}
fn inline_data_shard_fileinfo(
name: &str,
data_blocks: usize,
parity_blocks: usize,
erasure_index: usize,
distribution: &[usize],
data: Option<&'static [u8]>,
) -> FileInfo {
let mut fi = FileInfo::new(name, data_blocks, parity_blocks);
fi.name = name.to_string();
fi.erasure.index = erasure_index;
fi.erasure.distribution = distribution.to_vec();
fi.data = data.map(Bytes::from_static);
fi
}
#[test]
fn collect_inline_data_shards_by_index_uses_distribution_order() {
let distribution = vec![3, 1, 5, 2, 4, 6];
let mut fi = FileInfo::new("object", 4, 2);
fi.erasure.distribution = distribution.clone();
let files = vec![
inline_data_shard_fileinfo("block-3", 4, 2, 3, &distribution, Some(b"c")),
inline_data_shard_fileinfo("block-1", 4, 2, 1, &distribution, Some(b"a")),
inline_data_shard_fileinfo("parity-5", 4, 2, 5, &distribution, Some(b"p")),
inline_data_shard_fileinfo("block-2", 4, 2, 2, &distribution, Some(b"b")),
inline_data_shard_fileinfo("block-4", 4, 2, 4, &distribution, Some(b"d")),
inline_data_shard_fileinfo("parity-6", 4, 2, 6, &distribution, Some(b"q")),
];
let data_files =
collect_inline_data_shard_fileinfos_by_index(&files, &fi, 4, |_| true).expect("all data shards should be collected");
assert_eq!(
data_files.iter().map(|file| file.name.as_str()).collect::<Vec<_>>(),
["block-1", "block-2", "block-3", "block-4"]
);
}
#[test]
fn collect_inline_data_shards_by_index_rejects_missing_data_shard() {
let distribution = vec![1, 2, 3, 4];
let mut fi = FileInfo::new("object", 2, 2);
fi.erasure.distribution = distribution.clone();
let files = vec![
inline_data_shard_fileinfo("block-1", 2, 2, 1, &distribution, Some(b"a")),
inline_data_shard_fileinfo("block-2", 2, 2, 2, &distribution, None),
inline_data_shard_fileinfo("parity-3", 2, 2, 3, &distribution, Some(b"p")),
inline_data_shard_fileinfo("parity-4", 2, 2, 4, &distribution, Some(b"q")),
];
assert!(collect_inline_data_shard_fileinfos_by_index(&files, &fi, 2, |_| true).is_none());
}
#[tokio::test]
async fn inline_data_shards_direct_read_reassembles_payload() {
let payload = b"small inline object payload that spans data shards";
let (erasure, files, read_length, checksum_algo) = inline_bitrot_files_for_payload(payload).await;
let mut readers = build_inline_bitrot_readers(
&files,
erasure.data_shards,
"bucket",
"object",
read_length,
erasure.shard_size(),
&checksum_algo,
false,
)
.await
.expect("inline bitrot readers should build");
assert_eq!(readers.len(), erasure.data_shards);
let body = try_read_inline_data_shards_direct(&mut readers, erasure.data_shards, read_length, payload.len())
.await
.expect("data shard direct read should succeed");
assert_eq!(body.as_ref(), payload);
}
#[tokio::test]
async fn inline_data_shards_direct_read_rejects_corrupt_shard() {
let payload = b"small inline object payload that will be corrupted";
let (erasure, mut files, read_length, checksum_algo) = inline_bitrot_files_for_payload(payload).await;
let first = files[0].data.as_mut().expect("first shard should exist");
let mut corrupted = first.to_vec();
let last = corrupted.last_mut().expect("encoded shard should not be empty");
*last ^= 0xff;
*first = Bytes::from(corrupted);
let mut readers = build_inline_bitrot_readers(
&files,
erasure.total_shard_count(),
"bucket",
"object",
read_length,
erasure.shard_size(),
&checksum_algo,
false,
)
.await
.expect("inline bitrot readers should build");
let body = try_read_inline_data_shards_direct(&mut readers, 4, read_length, payload.len()).await;
assert!(body.is_none());
}
#[test]
fn inline_data_shards_direct_read_requires_single_block() {
assert!(can_try_inline_data_shards_direct(1024, 1024));
assert!(!can_try_inline_data_shards_direct(0, 1024));
assert!(!can_try_inline_data_shards_direct(1025, 1024));
}
#[test]
fn inline_erasure_offset_helpers_match_erasure_methods() {
for uses_legacy in [false, true] {
let erasure = coding::Erasure::new_with_options(4, 2, 1024 * 1024, uses_legacy);
for object_size in [1usize, 1024, 100 * 1024, 1024 * 1024] {
assert_eq!(
inline_erasure_shard_size(erasure.block_size, erasure.data_shards, uses_legacy),
erasure.shard_size()
);
assert_eq!(
inline_erasure_shard_file_offset(
0,
object_size,
object_size,
erasure.block_size,
erasure.data_shards,
uses_legacy,
),
erasure.shard_file_offset(0, object_size, object_size)
);
}
}
}
#[tokio::test]
async fn direct_memory_inline_data_shards_direct_read_reassembles_single_block_payload() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let endpoint =
Endpoint::try_from(tempdir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let payload = vec![b'i'; 192 * 1024];
let (erasure, files, _read_length, _checksum_algo) = inline_bitrot_files_for_payload(&payload).await;
let mut fi = FileInfo::new("bucket/object", erasure.data_shards, erasure.parity_shards);
fi.size = payload.len() as i64;
fi.data = files[0].data.clone();
fi.add_object_part(1, String::new(), payload.len(), None, payload.len() as i64, None, None);
let disks = vec![Some(disk); erasure.total_shard_count()];
let metrics_size_bucket = rustfs_io_metrics::get_object_size_bucket(fi.size);
let body = SetDisks::try_get_object_direct_data_shards_with_fileinfo(
"bucket",
"object",
&fi,
&files,
&disks,
true,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
metrics_size_bucket,
)
.await
.expect("direct-memory inline data shard read should not fail")
.expect("inline data shard path should be used");
assert_eq!(body.as_ref(), payload);
}
#[tokio::test]
async fn direct_memory_data_shards_direct_read_reassembles_single_block_payload() {
use uuid::Uuid;
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let endpoint =
Endpoint::try_from(tempdir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let bucket = "bucket";
let object = "object";
let payload = vec![b'd'; 192 * 1024];
disk.make_volume(bucket).await.expect("bucket should be created");
let mut fi = FileInfo::new(&format!("{bucket}/{object}"), 1, 0);
let data_dir = Uuid::new_v4();
fi.data_dir = Some(data_dir);
fi.size = payload.len() as i64;
fi.add_object_part(1, String::new(), payload.len(), None, payload.len() as i64, None, None);
let erasure = coding::Erasure::new_with_options(
fi.erasure.data_blocks,
fi.erasure.parity_blocks,
fi.erasure.block_size,
fi.uses_legacy_checksum,
);
let shard_path = format!("{object}/{data_dir}/part.1");
let checksum_info = fi.erasure.get_checksum_info(1);
let mut bitrot_writer = create_bitrot_writer(
true,
None,
bucket,
&shard_path,
payload.len() as i64,
erasure.shard_size(),
checksum_info.algorithm.clone(),
)
.await
.expect("bitrot writer should be created");
for chunk in payload.chunks(erasure.shard_size()) {
bitrot_writer.write(chunk).await.expect("payload chunk should be written");
}
let encoded = bitrot_writer.into_inline_data().expect("bitrot encoded data should exist");
disk.write_all(bucket, &shard_path, Bytes::from(encoded))
.await
.expect("encoded shard should be stored");
let files = vec![fi.clone()];
let disks = vec![Some(disk)];
let metrics_size_bucket = rustfs_io_metrics::get_object_size_bucket(fi.size);
let body = SetDisks::try_get_object_direct_data_shards_with_fileinfo(
bucket,
object,
&fi,
&files,
&disks,
true,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
metrics_size_bucket,
)
.await
.expect("direct-memory data shard read should not fail")
.expect("single-block data shard path should be used");
assert_eq!(body.as_ref(), payload);
}
#[tokio::test]
async fn range_reads_use_shard_span_length_for_non_zero_offsets() {
use tokio::io::AsyncReadExt;
use uuid::Uuid;
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let endpoint =
Endpoint::try_from(tempdir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let bucket = "bucket";
let object = "object";
let payload = vec![b'x'; 3 * 1024 * 1024 + 1234];
let range_offset = 2 * 1024 * 1024 + 17;
let range_length = 512 * 1024;
disk.make_volume(bucket).await.expect("bucket should be created");
let mut fi = FileInfo::new(&format!("{bucket}/{object}"), 1, 0);
let data_dir = Uuid::new_v4();
fi.data_dir = Some(data_dir);
fi.size = payload.len() as i64;
fi.add_object_part(1, String::new(), payload.len(), None, payload.len() as i64, None, None);
let erasure = coding::Erasure::new_with_options(
fi.erasure.data_blocks,
fi.erasure.parity_blocks,
fi.erasure.block_size,
fi.uses_legacy_checksum,
);
let shard_path = format!("{object}/{data_dir}/part.1");
let checksum_info = fi.erasure.get_checksum_info(1);
let mut bitrot_writer = create_bitrot_writer(
true,
None,
bucket,
&shard_path,
payload.len() as i64,
erasure.shard_size(),
checksum_info.algorithm.clone(),
)
.await
.expect("bitrot writer should be created");
for chunk in payload.chunks(erasure.shard_size()) {
bitrot_writer.write(chunk).await.expect("payload chunk should be written");
}
let encoded = bitrot_writer.into_inline_data().expect("bitrot encoded data should exist");
disk.write_all(bucket, &shard_path, Bytes::from(encoded))
.await
.expect("encoded shard should be stored");
let files = vec![fi.clone()];
let disks = vec![Some(disk.clone())];
let (mut reader, mut writer) = tokio::io::duplex(range_length * 2);
let metrics_size_bucket = rustfs_io_metrics::get_object_size_bucket(fi.size);
let read_task = tokio::spawn(async move {
SetDisks::get_object_with_fileinfo(
bucket,
object,
range_offset,
range_length as i64,
&mut writer,
fi,
files,
&disks,
0,
0,
true,
false,
GET_OBJECT_PATH_LEGACY_DUPLEX,
GET_CODEC_STREAMING_OBJECT_CLASS_PLAIN_SINGLE_PART,
metrics_size_bucket,
)
.await
});
let mut out = Vec::new();
reader.read_to_end(&mut out).await.expect("range bytes should be readable");
read_task
.await
.expect("read task should complete")
.expect("range read should succeed");
assert_eq!(out, payload[range_offset..range_offset + range_length]);
}
#[test]
fn parts_after_marker_uses_marker_position() {
let part_numbers = (1..=1002).collect::<Vec<_>>();
let remaining = parts_after_marker(&part_numbers, 1000).expect("marker should exist");
assert_eq!(remaining, &[1001, 1002]);
}
#[test]
fn parts_after_marker_returns_none_for_missing_marker() {
let part_numbers = vec![1, 2, 3];
assert!(parts_after_marker(&part_numbers, 4).is_none());
}
#[test]
fn delete_file_info_version_id_maps_explicit_null_version_to_stored_null() {
assert_eq!(delete_file_info_version_id(Some(Uuid::nil())), None);
let version_id = Uuid::new_v4();
assert_eq!(delete_file_info_version_id(Some(version_id)), Some(version_id));
assert_eq!(delete_file_info_version_id(None), None);
}
#[test]
fn put_object_fast_path_selection_prefers_inline_only_when_inline_buffer_and_single_block() {
assert!(should_use_inline_small_fast_path(true, 1024, 4096));
assert!(!should_use_single_block_non_inline_fast_path(true, 1024, 4096));
assert!(matches!(classify_small_write_path(true, 1024, 4096), SmallWritePath::Inline));
assert!(!should_use_inline_small_fast_path(false, 1024, 4096));
assert!(should_use_single_block_non_inline_fast_path(false, 1024, 4096));
assert!(matches!(
classify_small_write_path(false, 1024, 4096),
SmallWritePath::SingleBlockNonInline
));
}
#[test]
fn put_object_fast_path_selection_rejects_zero_and_multi_block_payloads() {
assert!(!should_use_inline_small_fast_path(true, 0, 4096));
assert!(!should_use_single_block_non_inline_fast_path(false, 0, 4096));
assert!(matches!(classify_small_write_path(true, 0, 4096), SmallWritePath::Pipeline));
assert!(!should_use_inline_small_fast_path(true, -1, 4096));
assert!(!should_use_single_block_non_inline_fast_path(false, -1, 4096));
assert!(matches!(classify_small_write_path(false, -1, 4096), SmallWritePath::Pipeline));
assert!(!should_use_inline_small_fast_path(true, 8192, 4096));
assert!(!should_use_single_block_non_inline_fast_path(false, 8192, 4096));
assert!(matches!(classify_small_write_path(false, 8192, 4096), SmallWritePath::Pipeline));
}
#[test]
fn put_object_large_batch_path_only_applies_to_large_ordinary_puts() {
assert!(matches!(
classify_put_write_path(false, 64 * 1024 * 1024, 1024 * 1024),
SmallWritePath::PipelineBatchedLarge
));
assert!(matches!(
classify_put_write_path(false, 32 * 1024 * 1024, 1024 * 1024),
SmallWritePath::Pipeline
));
assert!(matches!(
classify_put_write_path(false, 31 * 1024 * 1024, 1024 * 1024),
SmallWritePath::Pipeline
));
assert!(matches!(
classify_put_write_path(true, 64 * 1024 * 1024, 1024 * 1024),
SmallWritePath::Pipeline
));
}
#[test]
fn put_object_classification_uses_only_known_storage_size() {
assert_eq!(known_put_object_storage_size(42), 42);
assert_eq!(
known_put_object_storage_size(HashReader::SIZE_PRESERVE_LAYER),
HashReader::SIZE_PRESERVE_LAYER
);
assert!(matches!(
classify_put_write_path(false, known_put_object_storage_size(HashReader::SIZE_PRESERVE_LAYER), 1024 * 1024),
SmallWritePath::Pipeline
));
assert!(matches!(
classify_put_write_path(false, known_put_object_storage_size(1024 * 1024), 1024 * 1024),
SmallWritePath::SingleBlockNonInline
));
}
#[test]
fn put_object_part_fast_path_selection_matches_single_block_non_inline_rules() {
assert!(should_use_single_block_non_inline_fast_path(false, 4096, 4096));
assert!(should_use_single_block_non_inline_fast_path(false, 2048, 4096));
assert!(!should_use_single_block_non_inline_fast_path(false, 4097, 4096));
assert!(!should_use_single_block_non_inline_fast_path(false, 0, 4096));
assert!(matches!(
classify_small_write_path(false, 4096, 4096),
SmallWritePath::SingleBlockNonInline
));
}
#[test]
fn multipart_put_large_batch_path_only_applies_at_128m_and_above() {
assert!(matches!(
classify_multipart_part_write_path(128 * 1024 * 1024, 1024 * 1024),
SmallWritePath::PipelineBatchedLarge
));
assert!(matches!(
classify_multipart_part_write_path(64 * 1024 * 1024, 1024 * 1024),
SmallWritePath::Pipeline
));
assert!(matches!(
classify_multipart_part_write_path(1024 * 1024, 1024 * 1024),
SmallWritePath::SingleBlockNonInline
));
}
#[test]
fn multipart_write_paths_use_distinct_metric_labels() {
assert_eq!(SmallWritePath::Pipeline.multipart_metric_label(), "multipart_write_pipeline");
assert_eq!(
SmallWritePath::PipelineBatchedLarge.multipart_metric_label(),
"multipart_write_pipeline_batched_large"
);
assert_eq!(
SmallWritePath::SingleBlockNonInline.multipart_metric_label(),
"multipart_write_single_block_non_inline"
);
}
#[test]
fn test_is_cold_storage_class() {
// Test cold storage classes
assert!(is_cold_storage_class(storageclass::DEEP_ARCHIVE));
assert!(is_cold_storage_class(storageclass::GLACIER));
assert!(is_cold_storage_class(storageclass::GLACIER_IR));
// Test non-cold storage classes
assert!(!is_cold_storage_class(storageclass::STANDARD));
assert!(!is_cold_storage_class(storageclass::RRS));
assert!(!is_cold_storage_class(storageclass::STANDARD_IA));
assert!(!is_cold_storage_class(storageclass::EXPRESS_ONEZONE));
}
#[test]
fn test_is_infrequent_access_class() {
// Test infrequent access classes
assert!(is_infrequent_access_class(storageclass::ONEZONE_IA));
assert!(is_infrequent_access_class(storageclass::STANDARD_IA));
assert!(is_infrequent_access_class(storageclass::INTELLIGENT_TIERING));
// Test frequent access classes
assert!(!is_infrequent_access_class(storageclass::STANDARD));
assert!(!is_infrequent_access_class(storageclass::RRS));
assert!(!is_infrequent_access_class(storageclass::DEEP_ARCHIVE));
assert!(!is_infrequent_access_class(storageclass::EXPRESS_ONEZONE));
}
// Regression test: `mc cp --storage-class STANDARD` on a tiered object (self-copy) must not
// return NotImplemented. When the source object is tiered (transitioned_object.tier is
// non-empty) the usecase layer in object_usecase.rs intentionally leaves metadata_only=false
// so that the full copy path is taken. SetDisks::copy_object must therefore accept a
// same-bucket/same-key call even when metadata_only=false.
//
// Currently this test FAILS because the guard at set_disk.rs:1579 unconditionally rejects
// !metadata_only with StorageError::NotImplemented. Once the fix is applied the test will
// pass (or progress further through the copy path before failing on missing disk data).
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn copy_object_tiered_self_copy_does_not_return_not_implemented() {
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
let set_disks = make_test_set_disks(vec![Arc::new(LocalClient::with_manager(Arc::new(
rustfs_lock::GlobalLockManager::new(),
)))])
.await;
// Simulate a tiered object: metadata_only is false (set_disk must handle the full copy),
// and transitioned_object.tier is non-empty (the object lives on a remote tier).
let mut src_info = ObjectInfo {
metadata_only: false,
transitioned_object: TransitionedObject {
tier: "NEXTCLOUD".to_string(),
..Default::default()
},
..Default::default()
};
let result = set_disks
.copy_object(
"bucket",
"object",
"bucket",
"object",
&mut src_info,
&ObjectOptions::default(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await;
// The copy must not be rejected with NotImplemented. Any other outcome (Ok or a
// different error such as missing-disk / quorum) is acceptable here.
if let Err(ref err) = result {
assert!(
!matches!(err, StorageError::NotImplemented),
"tiered self-copy returned NotImplemented — copy_object must handle \
metadata_only=false for same-key copies of tiered objects, got: {err}"
);
}
}
async fn make_local_bucket_test_set_disks() -> Arc<SetDisks> {
let format = FormatV3::new(1, 2);
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
mem::forget(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
0,
0,
endpoints,
format,
Vec::new(),
)
.await
}
async fn make_local_bucket_test_set_disks_with_missing_format() -> Arc<SetDisks> {
let format = FormatV3::new(1, 2);
let mut endpoints = Vec::new();
let mut disks = Vec::new();
for disk_idx in 0..2 {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_idx);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("disk should be created");
if disk_idx == 0 {
let mut disk_format = format.clone();
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
save_format_file(&Some(disk.clone()), &Some(disk_format))
.await
.expect("format should be saved");
}
mem::forget(dir);
endpoints.push(endpoint);
disks.push(Some(disk));
}
SetDisks::new(
"test-owner".to_string(),
Arc::new(RwLock::new(disks)),
2,
1,
0,
0,
endpoints,
format,
Vec::new(),
)
.await
}
#[tokio::test]
async fn bucket_operations_round_trip_without_panicking() {
let set_disks = make_local_bucket_test_set_disks().await;
let bucket = "bucket-roundtrip";
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let info = set_disks
.get_bucket_info(bucket, &BucketOptions::default())
.await
.expect("bucket info should be available");
assert_eq!(info.name, bucket);
let buckets = set_disks
.list_bucket(&BucketOptions::default())
.await
.expect("bucket listing should succeed");
assert!(buckets.iter().any(|entry| entry.name == bucket));
set_disks
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("bucket should be deleted");
}
#[tokio::test]
async fn set_level_listing_trait_methods_use_existing_listing_implementation() {
let set_disks = make_local_bucket_test_set_disks().await;
let bucket = "bucket-listing";
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"hello".to_vec());
set_disks
.put_object(bucket, "object", &mut reader, &ObjectOptions::default())
.await
.expect("object should be written");
let list_result = set_disks
.clone()
.list_objects_v2(bucket, "", None, None, 1000, false, None, false)
.await
.expect("set-level list_objects_v2 should succeed");
assert_eq!(list_result.objects.len(), 1);
assert_eq!(list_result.objects[0].name, "object");
let versions_result = set_disks
.clone()
.list_object_versions(bucket, "", None, None, None, 1000)
.await
.expect("set-level list_object_versions should succeed");
assert_eq!(versions_result.objects.len(), 1);
assert_eq!(versions_result.objects[0].name, "object");
let (tx, mut rx) = mpsc::channel(4);
set_disks
.clone()
.walk(CancellationToken::new(), bucket, "", tx, WalkOptions::default())
.await
.expect("set-level walk should succeed");
let mut walked_names = Vec::new();
while let Some(item) = rx.recv().await {
if let Some(object) = item.item {
walked_names.push(object.name);
}
}
assert!(walked_names.iter().any(|name| name == "object"));
}
#[tokio::test]
async fn set_level_heal_format_repairs_unformatted_disk() {
let set_disks = make_local_bucket_test_set_disks_with_missing_format().await;
let disk = {
let disks = set_disks.disks.read().await;
disks[1].clone().expect("second disk should exist")
};
let before = load_format_erasure(&disk, true)
.await
.expect_err("second disk should start unformatted");
assert_eq!(before, DiskError::UnformattedDisk);
let (heal_result, heal_err) = set_disks.heal_format(false).await.expect("heal_format should complete");
assert!(heal_err.is_none(), "heal_format should repair the local unformatted disk");
assert_eq!(heal_result.disk_count, 2);
assert_eq!(heal_result.set_count, 1);
assert_eq!(heal_result.after.drives[1].state, DriveState::Ok.to_string());
let repaired = load_format_erasure(&disk, true)
.await
.expect("second disk should contain a healed format");
assert_eq!(repaired.erasure.this, set_disks.format.erasure.sets[0][1]);
}
#[tokio::test]
async fn remaining_unsupported_trait_stubs_return_typed_errors() {
let set_disks = make_test_set_disks(Vec::new()).await;
let (heal_result, heal_err) = make_local_bucket_test_set_disks()
.await
.heal_format(false)
.await
.expect("heal_format should be callable on formatted disks");
assert!(matches!(heal_err, Some(StorageError::NoHealRequired)));
assert_eq!(heal_result.disk_count, 2);
let copy_part_err = set_disks
.copy_object_part(
"bucket",
"src",
"bucket",
"dst",
"upload-id",
1,
0,
1,
&ObjectInfo::default(),
&ObjectOptions::default(),
&ObjectOptions::default(),
)
.await
.expect_err("unsupported copy_object_part should return a typed error");
assert!(matches!(copy_part_err, StorageError::NotImplemented));
let abandoned_err = set_disks
.check_abandoned_parts("bucket", "object", &HealOpts::default())
.await
.expect_err("abandoned-parts check should stay in the upper reconciliation layer");
assert!(matches!(abandoned_err, StorageError::NotImplemented));
}
#[tokio::test]
async fn stat_all_dirs_returns_index_aligned_vector_for_offline_disks() {
// All-offline set: no real disk I/O needed. Isolates the length/index-alignment contract
// that heal_object_dir depends on when it zips `errs` against the full `disks` array.
let disks: Vec<Option<DiskStore>> = vec![None, None, None, None];
let errs = stat_all_dirs(&disks, "bucket", "object").await;
// Before the fix, offline disks contributed no future and the collected vector had length
// 0, so any zip against `disks` paired errors with the wrong disk. After the fix each slot
// is DiskNotFound, index-aligned with `disks`.
assert_eq!(
errs.len(),
disks.len(),
"stat_all_dirs must return one entry per disk slot to stay index-aligned"
);
for err in &errs {
assert!(
matches!(err, Some(DiskError::DiskNotFound)),
"offline (None) disk slot must map to DiskNotFound in-place, got {err:?}"
);
}
}
}