// 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. //! `SetDisks` — one erasure set's worth of disks and the object storage logic //! over them. Historically a single ~19.7k-line God-Object; split into a Core //! plus borrow-based operation families during backlog#815 (Epic #728). //! //! Module layout after the split: //! //! - `mod.rs` — the `SetDisks` core struct, its construction, and the shared //! inherent state/helpers the operation families borrow. //! - `ctx` — `SetDisksCtx`, the borrow context that hands operation units //! access to the core without cloning `Arc`s (P0, backlog#816). //! - `core::io_primitives` — the shared low-level read/write/erasure IO //! primitives (metadata-fanout quorum, bitrot readers, rename/delete, quorum //! helpers) that the operation families call through the core (P5, //! backlog#820). //! - `ops/` — one module per storage-api contract family, each //! `impl for SetDisks`: `ops::object` (`ObjectIO` + //! `ObjectOperations`, the object read/write hot path, P6/backlog#821), //! `ops::heal` (`HealOperations`, P1/backlog#817), `ops::multipart` //! (`MultipartOperations`, P2/backlog#818), `ops::list` (`ListOperations`) //! and `ops::bucket` (`BucketOperations`, P3+P4/backlog#819), `ops::locking` //! (`NamespaceLocking` + lock helpers, P7/backlog#822). //! - `read.rs` — the object-read operation pipeline (`get_object_*`, //! `read_version_optimized`) and its metadata cache, kept separate from the //! read primitives it drives. //! - `metadata.rs`, `replication.rs`, `shard_source.rs` — supporting helpers. // #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_object_lock_for_deletion, 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::instance::{InstanceContext, bootstrap_ctx}; 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, HTTPPreconditions, 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_with_tier_manager, 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, current_dirty_generation, 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, SUFFIX_RESTORE_OPERATION_ID, 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::{BuildHasher, Hash, Hasher}; use std::mem::{self}; use std::pin::Pin; use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; 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; pub(super) fn restore_operation_id_from_metadata(metadata: &HashMap) -> Result> { let Some(value) = rustfs_utils::http::metadata_compat::get_consistent_str(metadata, SUFFIX_RESTORE_OPERATION_ID) else { if rustfs_utils::http::metadata_compat::contains_key_str(metadata, SUFFIX_RESTORE_OPERATION_ID) { return Err(Error::other("invalid restore operation id metadata".to_string())); } return Ok(None); }; let id = Uuid::parse_str(value).map_err(|_| Error::other("invalid restore operation id metadata".to_string()))?; if id.is_nil() { return Err(Error::other("invalid restore operation id metadata".to_string())); } Ok(Some(id)) } pub(super) fn require_restore_operation_id(metadata: &HashMap, expected: Uuid) -> Result<()> { match restore_operation_id_from_metadata(metadata)? { Some(actual) if actual == expected => Ok(()), _ => Err(Error::other("restore operation id changed before copy-back".to_string())), } } pub(super) fn restore_commit_operation_id_from_metadata(metadata: &HashMap) -> Result> { if !metadata.contains_key(X_AMZ_RESTORE.as_str()) { return Ok(None); } restore_operation_id_from_metadata(metadata) } impl SetDisks { pub(super) async fn require_current_restore_operation_id( &self, bucket: &str, object: &str, opts: &ObjectOptions, expected: Option, mode: &str, ) -> Result<()> { let Some(expected) = expected else { return Ok(()); }; let read_opts = ObjectOptions { version_id: opts.version_id.clone(), versioned: opts.versioned, version_suspended: opts.version_suspended, no_lock: true, ..Default::default() }; let (current, _, _) = self.get_object_fileinfo(bucket, object, &read_opts, true, false).await?; restore_operation_id_from_metadata(¤t.metadata)? .filter(|actual| *actual == expected) .ok_or_else(|| Error::other(format!("restore operation id changed before {mode}: expected {expected}")))?; Ok(()) } } type ListObjectsV2Info = StorageListObjectsV2Info; type ListObjectVersionsInfo = StorageListObjectVersionsInfo; type ObjectInfoOrErr = StorageObjectInfoOrErr; type WalkOptions = StorageWalkOptions bool>; type InlineBitrotReader = coding::BitrotReader; 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 = 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 = 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"; /// Validate disk metadata at a boundary that may legitimately return a delete /// marker. Disk/RPC decode boundaries perform the full collection validation /// once; repeated quorum passes use the cheap erasure-geometry predicate for /// payload entries and the canonical marker predicate for pure delete markers. pub(in crate::set_disk) fn file_info_is_valid_for_metadata(file_info: &FileInfo) -> bool { file_info.has_valid_metadata_shape() } struct ObjectLockDiagGuard { guard: NamespaceLockGuard, enabled: bool, op: &'static str, bucket: Option, object: Option, owner: Option, mode: &'static str, acquired_at: Instant, } impl ObjectLockDiagGuard { fn new( guard: NamespaceLockGuard, enabled: bool, op: &'static str, bucket: Option, object: Option, owner: Option, mode: &'static str, ) -> Self { Self { guard, enabled, op, bucket, object, owner, mode, acquired_at: Instant::now(), } } /// Whether the underlying namespace lock's heartbeat has observed a /// refresh-quorum loss (backlog#899 Phase 2). Callers fence their commit /// point on this so a stale lock holder does not race a double-write. fn is_lock_lost(&self) -> bool { self.guard.is_lock_lost() } } 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, guard: Option, } impl AsyncRead for SetDiskLockGuardedReader { fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { 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, 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) { 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) { metadata.remove(RUSTFS_MULTIPART_BUCKET_KEY); metadata.remove(RUSTFS_MULTIPART_OBJECT_KEY); } fn should_persist_encryption_original_size(metadata: &HashMap) -> 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) } /// Per-set memoized capacity dirty scope. /// /// The disk endpoint/path identity of each slot in a set is immutable for the /// set's lifetime (heal replaces the [`DiskStore`] instance but keeps the same /// endpoint and root), so the dirty scope only needs to be built once instead /// of allocating a `String` per online disk on every successful write /// (backlog#1315). Slots are filled lazily as disks are observed online; once /// every slot has contributed, `complete` latches and the hot path returns the /// shared `Arc` without any scan. #[derive(Default, Debug)] struct CapacityScopeCache { /// Per-slot resolved disk identity; `None` slots have not been seen online. slots: Vec>, /// Deduplicated scope covering every slot resolved so far. scope: Option>, /// Latches once every slot is resolved; the scope is then stable. complete: bool, } impl CapacityScopeCache { /// Whether `disks` contains an online disk whose slot has not been recorded /// yet. Read-only, allocation-free (used under the read lock). fn has_unresolved_slot(&self, disks: &[Option]) -> bool { disks .iter() .enumerate() .any(|(idx, disk)| disk.is_some() && self.slots.get(idx).is_none_or(|slot| slot.is_none())) } } impl SetDisks { /// Return the memoized dirty scope for this set, resolving any newly-online /// slots first. Steady-state writes hit the fast path (a read lock and an /// `Arc` clone) with no per-disk `String` allocation. fn capacity_scope(&self, disks: &[Option]) -> Arc { { let cache = self.capacity_scope_cache.read().unwrap_or_else(|p| p.into_inner()); if let Some(scope) = cache.scope.as_ref() && (cache.complete || !cache.has_unresolved_slot(disks)) { return scope.clone(); } } self.resolve_capacity_scope(disks) } /// Slow path: fill newly-observed slots and rebuild the deduplicated scope. fn resolve_capacity_scope(&self, disks: &[Option]) -> Arc { let mut cache = self.capacity_scope_cache.write().unwrap_or_else(|p| p.into_inner()); if cache.slots.len() < self.set_drive_count { cache.slots.resize(self.set_drive_count, None); } let mut changed = false; for (idx, disk) in disks.iter().enumerate() { let Some(slot) = cache.slots.get_mut(idx) else { break; }; if slot.is_some() { continue; } if let Some(disk) = disk { *slot = Some(CapacityScopeDisk { endpoint: disk.endpoint().to_string(), drive_path: disk.to_string(), }); changed = true; } } if changed || cache.scope.is_none() { let mut unique = HashSet::with_capacity(cache.slots.len()); let mut scoped_disks = Vec::with_capacity(cache.slots.len()); for slot in cache.slots.iter().flatten() { if unique.insert(slot.clone()) { scoped_disks.push(slot.clone()); } } cache.complete = !cache.slots.is_empty() && cache.slots.iter().all(|slot| slot.is_some()); cache.scope = Some(Arc::new(CapacityScope { disks: scoped_disks })); } cache.scope.clone().unwrap_or_else(|| Arc::new(CapacityScope::default())) } /// Record the set's dirty scope after a successful write. /// /// The global dirty registry is only upgraded on the first write of each /// registry generation: once this set has marked its disks, subsequent /// writes skip the global mutex until a refresh drain advances the /// generation, forcing a re-mark (backlog#1315). The scope-token registry /// (multipart completion) still records per token so the app-side write /// settle can consume it. fn record_capacity_scope_if_needed(&self, scope_token: Option, disks: &[Option]) { let scope = self.capacity_scope(disks); if scope.disks.is_empty() { return; } let generation = current_dirty_generation(); if self.capacity_dirty_generation.load(Ordering::Acquire) != generation { // First write of this generation (or after a drain): upgrade the // global registry and cache the generation observed under its lock. let observed = record_global_dirty_scope((*scope).clone()); self.capacity_dirty_generation.store(observed, Ordering::Release); } if let Some(token) = scope_token { record_capacity_scope(token, (*scope).clone()); } } /// Mark healed disks dirty from the (infrequent) heal path. /// /// Heal passes disks in erasure-distribution order (`shuffle_disks`), not /// physical-slot order, so they must not seed the slot-indexed memo — doing /// so could record a disk under the wrong slot and drop another from the /// steady-state scope. Heal therefore builds an ad-hoc scope from the disks /// it actually rewrote and marks the global registry directly. This runs at /// heal frequency, so it does not use the per-generation skip fast-path /// (backlog#1315). fn record_healed_capacity_scope(&self, disks: &[Option]) { let scope = capacity_scope_from_disks(disks); if scope.disks.is_empty() { return; } // Do not advance the set's generation marker here: this ad-hoc scope is // only the subset of disks heal rewrote, whereas the marker asserts the // full set was marked. Leaving the marker untouched keeps the next write // free to upgrade the full-set scope if it has not been marked yet. let _ = record_global_dirty_scope(scope); } } /// Build an ad-hoc, deduplicated dirty scope from `disks`. Used by the heal /// path where disks are not in physical-slot order (backlog#1315). fn capacity_scope_from_disks(disks: &[Option]) -> 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 } } /// 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"; const GET_OBJECT_METADATA_CACHE_FENCE_SHARDS: u16 = 4096; // --- Codec Streaming Configuration --- const ENV_RUSTFS_GET_CODEC_STREAMING_ENABLE: &str = "RUSTFS_GET_CODEC_STREAMING_ENABLE"; // Emergency kill-switch, not the primary enablement knob. The single switch that // turns codec streaming on/off is `RUSTFS_GET_CODEC_STREAMING_ROLLOUT` (default // `off`). This flag defaults to `true` and only exists to force-disable the fast // path regardless of rollout (set to `false`). Body/header compatibility is // confirmed by the GET codec-streaming parity e2e net + bench A/B (backlog#1183), // so it no longer gates enablement. See `get_codec_streaming_reader_gate`. const DEFAULT_RUSTFS_GET_CODEC_STREAMING_ENABLE: bool = true; 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; // Primary single switch for the codec-streaming GET fast path. `off` (default) // keeps GET on the legacy duplex path; `on` (aliases `full`/`production`, plus // the legacy `internal`/`benchmark`) opts it in. Combine with // `..._ROLLOUT_PCT` for a partial (percentage-based) rollout. 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"; // Enabled by default (backlog#872): the early-stop path only engages for // requests `should_allow_metadata_early_stop` classifies as safe (metadata-only // reads without version_id / healing / free-version needs) and still requires // a full read-quorum agreement before stopping. Set the env var to `false` to // fall back to full-wait metadata fanout. const DEFAULT_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE: bool = true; 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; // --- Multipart Reader-Setup Prefetch Configuration (backlog#870) --- const ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: &str = "RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH"; const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true; static OBJECT_LOCK_DIAG_ENABLED: OnceLock = OnceLock::new(); mod core; mod ctx; mod metadata; mod ops; #[cfg(feature = "test-util")] pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier; pub(crate) use ops::object::body_cache_plaintext_len; mod read; mod replication; pub(crate) mod shard_source; #[cfg(all(test, feature = "test-util"))] mod transition_matrix_tests; pub use ops::heal_walk::HealWalkVersion; pub(crate) struct PreparedGetObjectMetadata { fi: FileInfo, files: Vec, disks: Vec>, object_info: Option, } impl PreparedGetObjectMetadata { pub(crate) fn object_info(&self) -> &ObjectInfo { self.object_info .as_ref() .expect("prepared GET metadata must retain its ObjectInfo until consumed") } pub(crate) fn take_object_info(&mut self) -> ObjectInfo { self.object_info .take() .expect("prepared GET metadata ObjectInfo must be consumed exactly once") } } tokio::task_local! { static PREPARED_GET_OBJECT_METADATA: std::cell::RefCell>; } #[cfg(test)] tokio::task_local! { static GET_OBJECT_INFO_CONVERSIONS: Arc; } fn build_get_object_info(fi: &FileInfo, bucket: &str, object: &str, versioned: bool) -> ObjectInfo { #[cfg(test)] let _ = GET_OBJECT_INFO_CONVERSIONS.try_with(|conversions| { conversions.fetch_add(1, Ordering::Relaxed); }); ObjectInfo::from_file_info(fi, bucket, object, versioned) } fn take_prepared_get_object_metadata() -> Option { PREPARED_GET_OBJECT_METADATA .try_with(|prepared| prepared.borrow_mut().take()) .ok() .flatten() } async fn with_prepared_get_object_metadata(metadata: PreparedGetObjectMetadata, future: F) -> F::Output where F: std::future::Future, { PREPARED_GET_OBJECT_METADATA .scope(std::cell::RefCell::new(Some(metadata)), future) .await } #[cfg(test)] mod prepared_get_object_metadata_tests { use super::*; use crate::ecstore_validation_blackbox::make_local_set_disks; use crate::object_api::{BLOCK_SIZE_V2, PutObjReader}; use crate::set_disk::core::io_primitives::disk_call_counters; use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions}; use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _}; use http::HeaderMap; use tokio::io::AsyncReadExt; #[tokio::test] async fn prepared_metadata_is_consumed_exactly_once() { let metadata = PreparedGetObjectMetadata { fi: FileInfo::default(), files: Vec::new(), disks: Vec::new(), object_info: None, }; with_prepared_get_object_metadata(metadata, async { assert!(take_prepared_get_object_metadata().is_some()); assert!(take_prepared_get_object_metadata().is_none()); }) .await; assert!(take_prepared_get_object_metadata().is_none()); } #[tokio::test] #[serial_test::serial(body_cache_hook)] async fn prepared_reader_reuses_metadata_fanout_exactly_once() { let (_dirs, set_disks) = make_local_set_disks(4, 2).await; let bucket = "prepared-metadata-fanout"; let object = "prepared-metadata-fanout-object.bin"; let payload = b"prepared-metadata-fanout-payload-".repeat(40_000); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut put_reader = PutObjReader::from_vec(payload.clone()); set_disks .put_object(bucket, object, &mut put_reader, &opts) .await .expect("object should be written"); let calls = disk_call_counters::observe(object); let conversions = Arc::new(AtomicU64::new(0)); let restored = GET_OBJECT_INFO_CONVERSIONS .scope(Arc::clone(&conversions), async { let metadata = set_disks .prepare_get_object_metadata(bucket, object, &opts) .await .expect("prepared metadata should resolve"); assert_eq!( calls.total(disk_call_counters::KIND_READ_VERSION), 4, "preparation should fan out to each online disk exactly once" ); let mut reader = set_disks .get_object_reader_with_prepared_metadata(bucket, object, None, HeaderMap::new(), &opts, metadata) .await .expect("prepared body reader should open"); let mut restored = Vec::new(); reader .stream .read_to_end(&mut restored) .await .expect("prepared body should stream"); restored }) .await; assert_eq!(restored, payload); assert_eq!( conversions.load(Ordering::Relaxed), 1, "prepared reader must consume the ObjectInfo built during metadata preparation" ); assert_eq!( calls.total(disk_call_counters::KIND_READ_VERSION), 4, "reader construction must consume prepared metadata instead of repeating the fanout" ); } #[tokio::test] #[serial_test::serial(body_cache_hook)] async fn prepared_reader_rebuilds_object_info_when_precomputed_value_is_absent() { let (_dirs, set_disks) = make_local_set_disks(4, 2).await; let bucket = "prepared-object-info-fallback"; let object = "prepared-object-info-fallback.bin"; let payload = b"prepared-object-info-fallback-payload".repeat(4_000); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut put_reader = PutObjReader::from_vec(payload.clone()); set_disks .put_object(bucket, object, &mut put_reader, &opts) .await .expect("object should be written"); let conversions = Arc::new(AtomicU64::new(0)); let restored = GET_OBJECT_INFO_CONVERSIONS .scope(Arc::clone(&conversions), async { let mut metadata = set_disks .prepare_get_object_metadata(bucket, object, &opts) .await .expect("prepared metadata should resolve"); metadata.object_info = None; let mut reader = set_disks .get_object_reader_with_prepared_metadata(bucket, object, None, HeaderMap::new(), &opts, metadata) .await .expect("reader should rebuild missing prepared ObjectInfo"); let mut restored = Vec::new(); reader .stream .read_to_end(&mut restored) .await .expect("fallback reader should stream"); restored }) .await; assert_eq!(restored, payload); assert_eq!( conversions.load(Ordering::Relaxed), 2, "missing precomputed ObjectInfo must trigger exactly one structural fallback rebuild" ); } #[tokio::test] #[serial_test::serial(body_cache_hook)] async fn prepared_reader_restores_full_body_with_one_offline_shard() { let (_dirs, set_disks) = make_local_set_disks(4, 2).await; let bucket = "prepared-reader-offline-shard"; let object = "prepared-reader-offline-shard-object.bin"; let payload = (0..(BLOCK_SIZE_V2 + 321)) .map(|idx| ((idx * 19) % 251) as u8) .collect::>(); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut put_reader = PutObjReader::from_vec(payload.clone()); set_disks .put_object(bucket, object, &mut put_reader, &opts) .await .expect("object should be written"); set_disks.disks.write().await[0] = None; let metadata = set_disks .prepare_get_object_metadata(bucket, object, &opts) .await .expect("prepared metadata should tolerate one offline shard"); let mut reader = set_disks .get_object_reader_with_prepared_metadata(bucket, object, None, HeaderMap::new(), &opts, metadata) .await .expect("prepared body reader should open with one offline shard"); let mut restored = Vec::new(); reader .stream .read_to_end(&mut restored) .await .expect("degraded prepared body should stream"); assert_eq!(restored, payload); } } impl SetDisks { pub(crate) async fn prepare_get_object_metadata( &self, bucket: &str, object: &str, opts: &ObjectOptions, ) -> Result { let (fi, files, disks) = self.get_object_fileinfo(bucket, object, opts, true, true).await?; let object_info = build_get_object_info(&fi, bucket, object, opts.versioned || opts.version_suspended); Ok(PreparedGetObjectMetadata { fi, files, disks, object_info: Some(object_info), }) } pub(crate) async fn get_object_reader_with_prepared_metadata( &self, bucket: &str, object: &str, range: Option, headers: HeaderMap, opts: &ObjectOptions, metadata: PreparedGetObjectMetadata, ) -> Result { with_prepared_get_object_metadata(metadata, self.get_object_reader(bucket, object, range, headers, opts)).await } } /// 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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, ) }) } } /// Check if multipart reads prefetch the next part's bitrot reader setup /// while the current part decodes (backlog#870). /// /// **Note**: Cached via `OnceLock` in production. In test builds the env var /// is read directly so that `temp_env` overrides take effect. fn is_multipart_reader_setup_prefetch_enabled() -> bool { #[cfg(test)] { rustfs_utils::get_env_bool( ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH, DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH, ) } #[cfg(not(test))] { static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| { rustfs_utils::get_env_bool( ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH, DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH, ) }) } } // --- 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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() { // Clean production token. `internal`/`benchmark` remain accepted aliases // for backward compatibility; all three opt the fast path in. value if value.eq_ignore_ascii_case("on") || value.eq_ignore_ascii_case("full") || value.eq_ignore_ascii_case("production") => { GetCodecStreamingRollout::On } value if value.eq_ignore_ascii_case("internal") => GetCodecStreamingRollout::Internal, value if value.eq_ignore_ascii_case("benchmark") => GetCodecStreamingRollout::Benchmark, _ => GetCodecStreamingRollout::Off, } } /// Emergency kill-switch (defaults to `true`). Set /// `RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED=false` to force the fast path /// off. Body compatibility is confirmed by the parity e2e net + bench (backlog#1183), /// so this no longer gates enablement — the `..._ROLLOUT` switch does. fn is_get_codec_streaming_body_compat_confirmed() -> bool { rustfs_utils::get_env_bool(ENV_RUSTFS_GET_CODEC_STREAMING_BODY_COMPAT_CONFIRMED, true) } /// Emergency kill-switch (defaults to `true`). Set /// `RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED=false` to force the fast path /// off. Header compatibility is confirmed by the parity e2e net + bench (backlog#1183), /// so this no longer gates enablement — the `..._ROLLOUT` switch does. fn is_get_codec_streaming_header_compat_confirmed() -> bool { rustfs_utils::get_env_bool(ENV_RUSTFS_GET_CODEC_STREAMING_HEADER_COMPAT_CONFIRMED, true) } fn build_get_codec_streaming_decode_engine(erasure: coding::Erasure) -> std::io::Result { 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 { /// Default. Codec streaming disabled; GET uses the legacy duplex path. Off, /// Production enablement token (`on`/`full`/`production`). On, /// Backward-compatible aliases that also opt the fast path in. 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, PartNumber, 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::PartNumber => "part_number", 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, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GetDirectMemoryDecision { Use { object_size: usize }, Fallback(GetDirectMemoryFallbackReason), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GetDirectMemoryFallbackReason { Disabled, ThresholdZero, Range, PartNumber, VersionId, Versioned, VersionSuspended, InclFreeVersions, SkipFreeVersion, DataMovement, RawDataMovementRead, DeleteMarker, MetadataOnly, VersionOnly, Encrypted, Compressed, Remote, ObjectInfoMultipart, FileInfoMultipart, InvalidSize, SizeMismatch, AboveThreshold, } impl GetDirectMemoryFallbackReason { const fn as_str(self) -> &'static str { match self { Self::Disabled => "disabled", Self::ThresholdZero => "threshold_zero", Self::Range => "range", Self::PartNumber => "part_number", Self::VersionId => "version_id", Self::Versioned => "versioned", Self::VersionSuspended => "version_suspended", Self::InclFreeVersions => "incl_free_versions", Self::SkipFreeVersion => "skip_free_version", Self::DataMovement => "data_movement", Self::RawDataMovementRead => "raw_data_movement_read", Self::DeleteMarker => "delete_marker", Self::MetadataOnly => "metadata_only", Self::VersionOnly => "version_only", Self::Encrypted => "encrypted", Self::Compressed => "compressed", Self::Remote => "remote", Self::ObjectInfoMultipart => "object_info_multipart", Self::FileInfoMultipart => "file_info_multipart", Self::InvalidSize => "invalid_size", Self::SizeMismatch => "size_mismatch", Self::AboveThreshold => "above_threshold", } } } 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_direct_memory_decision( object_class: GetCodecStreamingObjectClass, decision: GetDirectMemoryDecision, size_bucket: &'static str, ) { let (outcome, reason) = match decision { GetDirectMemoryDecision::Use { .. } => ( crate::diagnostics::get::GET_DIRECT_MEMORY_DECISION_USE, crate::diagnostics::get::GET_DIRECT_MEMORY_REASON_NONE, ), GetDirectMemoryDecision::Fallback(reason) => { (crate::diagnostics::get::GET_DIRECT_MEMORY_DECISION_FALLBACK, reason.as_str()) } }; rustfs_io_metrics::record_get_object_direct_memory_decision(outcome, object_class.as_str(), 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, 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, object_info: &ObjectInfo, fi: &FileInfo, opts: &ObjectOptions, threshold: usize, ) -> bool { matches!( get_small_object_direct_memory_decision_with_threshold(range, object_info, fi, opts, true, threshold), GetDirectMemoryDecision::Use { .. } ) } fn get_small_object_direct_memory_decision_with_threshold( range: &Option, object_info: &ObjectInfo, fi: &FileInfo, opts: &ObjectOptions, enabled: bool, threshold: usize, ) -> GetDirectMemoryDecision { if !enabled { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Disabled); } if threshold == 0 { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::ThresholdZero); } if range.is_some() { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Range); } if opts.part_number.is_some() { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::PartNumber); } if opts.version_id.is_some() { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::VersionId); } if opts.versioned { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Versioned); } if opts.version_suspended { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::VersionSuspended); } if opts.incl_free_versions { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::InclFreeVersions); } if opts.skip_free_version { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::SkipFreeVersion); } if opts.data_movement { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::DataMovement); } if opts.raw_data_movement_read { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::RawDataMovementRead); } if object_info.delete_marker { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::DeleteMarker); } if object_info.metadata_only { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::MetadataOnly); } if object_info.version_only { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::VersionOnly); } if object_info.is_encrypted() { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Encrypted); } if object_info.is_compressed() { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Compressed); } if object_info.is_remote() { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Remote); } if object_info.parts.len() != 1 { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::ObjectInfoMultipart); } if fi.parts.len() != 1 { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::FileInfoMultipart); } let Ok(object_size) = usize::try_from(fi.size) else { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::InvalidSize); }; if object_size == 0 { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::InvalidSize); } if object_info.size != fi.size { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::SizeMismatch); } if object_size > threshold { return GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::AboveThreshold); } GetDirectMemoryDecision::Use { object_size } } fn get_small_object_direct_memory_decision( range: &Option, object_info: &ObjectInfo, fi: &FileInfo, opts: &ObjectOptions, ) -> GetDirectMemoryDecision { get_small_object_direct_memory_decision_with_threshold( range, object_info, fi, opts, is_get_small_object_direct_memory_enabled(), 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, part_number: Option, 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, }; } // A partNumber GET arrives with `range == None`, so it is not caught by the // Range class above, yet it still requires a non-zero storage offset/length // (synthesized from the part size). The codec-streaming path builds a // full-object reader and drops the offset/length returned by // `GetObjectReader::new`, so partNumber >= 2 would stream the whole object. // Mirror the direct-memory part_number fallback and route these requests back // to the legacy duplex path, which applies the offset/length correctly. if part_number.is_some() { return GetCodecStreamingGate { object_class, decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::PartNumber), 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, } fn log_multipart_write_quorum_failure( context: MultipartWriteQuorumContext<'_>, errs: &[Option], 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) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct WriteLayout { data_drives: usize, parity_drives: usize, write_quorum: usize, } impl WriteLayout { fn from_parity(drive_count: usize, parity_drives: usize) -> Result { let max_parity = drive_count / 2; if parity_drives > max_parity { return Err(Error::other(format!( "write parity {parity_drives} exceeds the maximum {max_parity} for {drive_count} drives" ))); } let data_drives = drive_count .checked_sub(parity_drives) .filter(|&data_drives| data_drives > 0 && parity_drives <= data_drives) .ok_or_else(|| Error::other(format!("invalid write layout with {drive_count} drives and parity {parity_drives}")))?; let write_quorum = data_drives .checked_add(usize::from(data_drives == parity_drives)) .filter(|&write_quorum| write_quorum <= drive_count) .ok_or_else(|| Error::other(format!("invalid write quorum for {drive_count} drives and parity {parity_drives}")))?; Ok(Self { data_drives, parity_drives, write_quorum, }) } } pub(super) fn resolve_write_layout( config: &storageclass::Config, pool_index: usize, drive_count: usize, fallback_parity: usize, storage_class: Option<&str>, max_parity: bool, ) -> Result { let configured_parity = if config.is_initialized() { config .parity_for_pool(storage_class.unwrap_or_default(), pool_index, drive_count) .ok_or_else(|| { Error::other(format!("storage class layout does not match pool {pool_index} with {drive_count} drives")) })? } else { fallback_parity }; let parity_drives = if max_parity { drive_count / 2 } else { configured_parity }; WriteLayout::from_parity(drive_count, parity_drives) } #[cfg(test)] mod write_layout_tests { use super::{WriteLayout, resolve_write_layout}; use crate::config::storageclass::{ CLASS_RRS, CLASS_STANDARD, INLINE_BLOCK_ENV, OPTIMIZE_ENV, RRS, RRS_ENV, STANDARD_ENV, lookup_config_for_pools, lookup_config_for_pools_without_env, }; use arc_swap::ArcSwap; use rustfs_config::server_config::KVS; use std::sync::Arc; #[test] fn automatic_standard_layout_is_resolved_per_pool() { let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]) .expect("automatic storage class should resolve for both pools"); assert_eq!( resolve_write_layout(&config, 0, 4, 2, None, false).expect("first pool should resolve"), WriteLayout { data_drives: 2, parity_drives: 2, write_quorum: 3, } ); assert_eq!( resolve_write_layout(&config, 1, 2, 1, None, false).expect("second pool should resolve"), WriteLayout { data_drives: 1, parity_drives: 1, write_quorum: 2, } ); } #[test] fn reduced_redundancy_layout_allows_single_disk_zero_parity() { let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 1]).expect("reduced redundancy should resolve for both pools"); assert_eq!( resolve_write_layout(&config, 0, 4, 2, Some(RRS), false).expect("four-drive RRS pool should resolve"), WriteLayout { data_drives: 3, parity_drives: 1, write_quorum: 3, } ); assert_eq!( resolve_write_layout(&config, 1, 1, 0, Some(RRS), false).expect("single-drive RRS pool should resolve"), WriteLayout { data_drives: 1, parity_drives: 0, write_quorum: 1, } ); } #[test] fn write_layout_rejects_unknown_topology_and_invalid_parity() { let config = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("test storage class should resolve"); assert!(resolve_write_layout(&config, 2, 2, 1, None, false).is_err()); assert!(resolve_write_layout(&config, 1, 4, 2, None, false).is_err()); assert!(WriteLayout::from_parity(4, 3).is_err()); assert!(WriteLayout::from_parity(0, 0).is_err()); let mut zero_parity_kvs = KVS::new(); zero_parity_kvs.insert(CLASS_STANDARD.to_string(), "EC:0".to_string()); zero_parity_kvs.insert(CLASS_RRS.to_string(), "EC:0".to_string()); let zero_parity = lookup_config_for_pools_without_env(&zero_parity_kvs, &[4]).expect("zero-parity config should resolve"); assert_eq!( resolve_write_layout(&zero_parity, 0, 4, 2, None, true).expect("max parity should override configured parity"), WriteLayout { data_drives: 2, parity_drives: 2, write_quorum: 3, } ); } #[test] fn only_uninitialized_config_falls_back_to_pool_startup_parity() { let uninitialized = crate::config::storageclass::Config::default(); assert_eq!( resolve_write_layout(&uninitialized, 99, 2, 0, None, false) .expect("uninitialized config should preserve the pool's startup fallback"), WriteLayout { data_drives: 2, parity_drives: 0, write_quorum: 2, } ); let initialized = lookup_config_for_pools_without_env(&KVS::new(), &[4, 2]).expect("initialized config should resolve"); assert!(resolve_write_layout(&initialized, 99, 2, 1, None, false).is_err()); assert!(resolve_write_layout(&initialized, 1, 4, 2, None, false).is_err()); } #[test] #[serial_test::serial(storage_class_env)] fn held_snapshot_keeps_parity_and_inline_policy_consistent_across_reload() { let old = temp_env::with_vars( [ (STANDARD_ENV, Some("")), (RRS_ENV, Some("")), (OPTIMIZE_ENV, None), (INLINE_BLOCK_ENV, Some("1KiB")), ], || lookup_config_for_pools(&KVS::new(), &[4, 2]), ) .expect("old config should resolve"); let new = temp_env::with_vars( [ (STANDARD_ENV, Some("EC:1")), (RRS_ENV, Some("EC:1")), (OPTIMIZE_ENV, None), (INLINE_BLOCK_ENV, Some("0B")), ], || lookup_config_for_pools(&KVS::new(), &[4, 2]), ) .expect("new config should resolve"); let published = ArcSwap::from_pointee(old); let held = published.load_full(); published.store(Arc::new(new)); let held_layout = resolve_write_layout(&held, 0, 4, 2, None, false).expect("held snapshot should remain valid"); assert_eq!(held_layout.parity_drives, 2); assert!(held.should_inline(512, false)); let current = published.load_full(); let current_layout = resolve_write_layout(¤t, 0, 4, 2, None, false).expect("new snapshot should resolve"); assert_eq!(current_layout.parity_drives, 1); assert!(!current.should_inline(512, false)); } } fn build_tiered_decommission_file_info(bucket: &str, object: &str, fi: &FileInfo, layout: WriteLayout) -> FileInfo { let WriteLayout { data_drives, parity_drives, .. } = layout; let mut updated = fi.clone(); updated.erasure = FileInfo::new([bucket, object].join("/").as_str(), data_drives, parity_drives).erasure; updated } fn resolve_tiered_decommission_write_quorum_result( errs: &[Option], 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>>>, pub set_endpoints: Vec, 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>>>, get_object_metadata_cache: moka::future::Cache>, get_object_metadata_cache_hash_builder: std::collections::hash_map::RandomState, get_object_metadata_cache_generations: Arc<[AtomicU64]>, pub lockers: Vec>, local_lock_manager: Arc, /// Per-instance runtime context (Phase 5, backlog#939). /// /// The leaf of the object-graph ctx plumbing (ECStore → Sets → SetDisks). /// Slice 3 sources `local_lock_manager` from this context to give each /// instance its own lock namespace. ctx: Arc, /// Memoized capacity dirty scope so successful writes reuse a prebuilt /// `Arc` instead of allocating a `String` per online disk (backlog#1315). /// `Arc` so clones of a set share one memo. capacity_scope_cache: Arc>, /// Last global dirty-registry generation this set marked. `u64::MAX` until /// the first write; equality with the current generation lets steady-state /// writes skip the global registry mutex (backlog#1315). `Arc` so clones of /// a set share one generation marker. capacity_dirty_generation: Arc, #[cfg(test)] storage_class_config_override: Arc>>>, } #[derive(Clone, Debug, Eq, PartialEq)] struct GetObjectMetadataCacheKey { bucket: Arc, object: Arc, generation: u64, hash: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct GetObjectMetadataCacheGeneration { index: usize, value: u64, hash: u64, } #[cfg(test)] struct MetadataCacheInvalidationProbeState { bucket: String, object: String, count: AtomicU64, } #[cfg(test)] struct MetadataCacheInvalidationProbe { state: Arc, } #[cfg(test)] static METADATA_CACHE_INVALIDATION_PROBE: std::sync::OnceLock< std::sync::Mutex>>, > = std::sync::OnceLock::new(); #[cfg(test)] impl MetadataCacheInvalidationProbe { fn install(bucket: &str, object: &str) -> Self { let state = Arc::new(MetadataCacheInvalidationProbeState { bucket: bucket.to_string(), object: object.to_string(), count: AtomicU64::new(0), }); let mut slot = METADATA_CACHE_INVALIDATION_PROBE .get_or_init(|| std::sync::Mutex::new(None)) .lock() .expect("metadata cache invalidation probe mutex should not poison"); assert!( slot.is_none(), "metadata cache invalidation probe must be installed by one test at a time" ); *slot = Some(Arc::clone(&state)); drop(slot); Self { state } } fn count(&self) -> u64 { self.state.count.load(Ordering::Acquire) } } #[cfg(test)] impl Drop for MetadataCacheInvalidationProbe { fn drop(&mut self) { let mut slot = METADATA_CACHE_INVALIDATION_PROBE .get_or_init(|| std::sync::Mutex::new(None)) .lock() .expect("metadata cache invalidation probe mutex should not poison"); if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) { *slot = None; } } } #[cfg(test)] fn record_metadata_cache_invalidation(bucket: &str, object: &str) { let probe = METADATA_CACHE_INVALIDATION_PROBE .get_or_init(|| std::sync::Mutex::new(None)) .lock() .expect("metadata cache invalidation probe mutex should not poison") .as_ref() .filter(|probe| probe.bucket == bucket && probe.object == object) .cloned(); if let Some(probe) = probe { probe.count.fetch_add(1, Ordering::AcqRel); } } impl GetObjectMetadataCacheKey { fn new(bucket: &str, object: &str, generation: GetObjectMetadataCacheGeneration) -> Self { Self { bucket: Arc::from(bucket), object: Arc::from(object), generation: generation.value, hash: generation.hash, } } } impl Hash for GetObjectMetadataCacheKey { fn hash(&self, state: &mut H) { self.hash.hash(state); self.generation.hash(state); } } #[derive(Clone, Debug)] struct GetObjectMetadataCacheEntry { #[allow(dead_code)] // Kept for debugging; moka handles TTL internally created_at: Instant, fi: FileInfo, parts_metadata: Vec, online_disks: Vec>, read_quorum: usize, } #[derive(Clone, Debug)] struct DiskHealthEntry { last_check: Instant, online: bool, } impl DiskHealthEntry { fn cached_value(&self) -> Option { if self.last_check.elapsed() <= DISK_HEALTH_CACHE_TTL { Some(self.online) } else { None } } } impl SetDisks { fn storage_class_config_snapshot(&self) -> Arc { #[cfg(test)] if let Some(config) = self .storage_class_config_override .read() .expect("test storage class override lock should not be poisoned") .as_ref() { return config.clone(); } runtime_sources::storage_class_config_snapshot() } #[cfg(test)] pub(crate) fn set_test_storage_class_config(&self, config: storageclass::Config) { *self .storage_class_config_override .write() .expect("test storage class override lock should not be poisoned") = Some(Arc::new(config)); } fn get_object_metadata_cache_hash(&self, bucket: &str, object: &str) -> u64 { let mut hasher = self.get_object_metadata_cache_hash_builder.build_hasher(); bucket.hash(&mut hasher); object.hash(&mut hasher); hasher.finish() } fn get_object_metadata_cache_generation(&self, bucket: &str, object: &str) -> Option { let hash = self.get_object_metadata_cache_hash(bucket, object); let hash_bytes = hash.to_le_bytes(); let index = usize::from(u16::from_le_bytes([hash_bytes[0], hash_bytes[1]]) % GET_OBJECT_METADATA_CACHE_FENCE_SHARDS); let value = self.get_object_metadata_cache_generations[index].load(Ordering::Acquire); (value != u64::MAX).then_some(GetObjectMetadataCacheGeneration { index, value, hash }) } fn is_get_object_metadata_cache_generation_current(&self, generation: GetObjectMetadataCacheGeneration) -> bool { self.get_object_metadata_cache_generations[generation.index].load(Ordering::Acquire) == generation.value } async fn invalidate_get_object_metadata_cache(&self, bucket: &str, object: &str) { let hash = self.get_object_metadata_cache_hash(bucket, object); let hash_bytes = hash.to_le_bytes(); let index = usize::from(u16::from_le_bytes([hash_bytes[0], hash_bytes[1]]) % GET_OBJECT_METADATA_CACHE_FENCE_SHARDS); let generation = &self.get_object_metadata_cache_generations[index]; let previous = match generation.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| current.checked_add(1)) { Ok(previous) | Err(previous) => previous, }; let previous = GetObjectMetadataCacheGeneration { index, value: previous, hash, }; self.get_object_metadata_cache .invalidate(&GetObjectMetadataCacheKey::new(bucket, object, previous)) .await; #[cfg(test)] record_metadata_cache_invalidation(bucket, object); } fn invalidate_all_get_object_metadata_cache(&self) { for generation in self.get_object_metadata_cache_generations.iter() { let _ = generation.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| current.checked_add(1)); } self.get_object_metadata_cache.invalidate_all(); } async fn acquire_read_lock_diag(&self, op: &'static str, bucket: &str, object: &str) -> Result { crate::hp_guard!("SetDisks::acquire_read_lock"); 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 { crate::hp_guard!("SetDisks::acquire_write_lock"); 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>>>, set_drive_count: usize, default_parity_count: usize, set_index: usize, pool_index: usize, set_endpoints: Vec, format: FormatV3, lockers: Vec>, ) -> Arc { Self::new_with_instance_ctx( locker_owner, disks, set_drive_count, default_parity_count, set_index, pool_index, set_endpoints, format, lockers, bootstrap_ctx(), ) .await } /// Build a set bound to an explicit instance context (Phase 5 follow-up, /// backlog#1052). The legacy [`SetDisks::new`] entry adopts the process /// bootstrap context; a store constructed around its own context threads it /// down here so the whole object graph shares one cell. #[allow(clippy::too_many_arguments)] pub async fn new_with_instance_ctx( locker_owner: String, disks: Arc>>>, set_drive_count: usize, default_parity_count: usize, set_index: usize, pool_index: usize, set_endpoints: Vec, format: FormatV3, lockers: Vec>, instance_ctx: Arc, ) -> Arc { let ctx = instance_ctx; 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(), get_object_metadata_cache_hash_builder: std::collections::hash_map::RandomState::new(), get_object_metadata_cache_generations: Arc::from( (0..usize::from(GET_OBJECT_METADATA_CACHE_FENCE_SHARDS)) .map(|_| AtomicU64::new(0)) .collect::>(), ), lockers, // Sourced from the instance context so each instance owns its lock // namespace (Phase 5 Slice 3). Single-instance: ctx aliases the // process lock-manager singleton, so this is unchanged. local_lock_manager: ctx.lock_manager(), ctx, capacity_scope_cache: Arc::new(std::sync::RwLock::new(CapacityScopeCache::default())), capacity_dirty_generation: Arc::new(AtomicU64::new(u64::MAX)), #[cfg(test)] storage_class_config_override: Arc::new(std::sync::RwLock::new(None)), }) } /// This set's per-instance runtime context (Phase 5, backlog#939). #[allow(dead_code)] // Read by tests; consumed by later slices. pub(crate) fn instance_ctx(&self) -> &Arc { &self.ctx } /// The lock manager this set actually uses (test-only; Phase 5 Slice 3). #[cfg(test)] pub(crate) fn local_lock_manager_for_test(&self) -> &Arc { &self.local_lock_manager } // async fn cached_disk_health(&self, index: usize) -> Option { // 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>) -> (Vec>, 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], bucket: &str, object: &str, buff: Vec) -> Vec> { // 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>>, Vec>) { // 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(()) // } } fn is_explicit_null_version(version_id: Option) -> bool { version_id == Some(Uuid::nil()) } fn delete_file_info_version_id(version_id: Option) -> Option { 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) } fn should_use_inline_fast_path( range: &Option, object_info: &ObjectInfo, fi: &FileInfo, opts: &ObjectOptions, ) -> bool { object_info.is_inline_fast_path_eligible() && fi.data.is_some() && range.is_none() && opts.part_number.is_none() } 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>> { 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>> { 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], data_shards: usize, read_length: usize, object_size: usize, ) -> Option { 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> { 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.has_valid_erasure_geometry() { 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() } impl SetDisks { async fn acquire_dist_delete_object_locks_batch( &self, batch: &rustfs_lock::BatchLockRequest, ) -> (HashMap<(String, String), String>, HashSet, Vec>) { let requests: Vec = 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![Vec::new(); requests.len()]; let mut errors_by_object: Vec> = 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>) { 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> = 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> { self.get_disks_internal().await } } 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(()) } /// Whether the batch-delete path must stat each object under the held lock to /// run [`check_object_lock_delete`] (the #4297 protection). /// /// Under S3 semantics retention/legal-hold metadata can only be written to /// buckets created with Object Lock enabled (`validate_bucket_object_lock_enabled` /// guards every write surface), and default retention only exists with a bucket /// lock configuration, so for buckets without Object Lock the per-object stat in /// `delete_objects` has no consumer and can be skipped (backlog#929 / HP-8). /// /// Fail closed: when bucket metadata cannot be resolved the check stays on, so /// object-lock protection is never skipped because of a metadata lookup miss. pub(crate) fn object_lock_delete_check_required(bucket_meta: Option<&crate::bucket::metadata::BucketMetadata>) -> bool { bucket_meta.is_none_or(|meta| meta.object_locking()) } async fn check_object_lock_delete(bucket: &str, object: &str, obj_info: &ObjectInfo, opts: &ObjectOptions) -> Result<()> { if set_disk_delete_creates_delete_marker(opts) { return Ok(()); } let bypass_governance = opts .object_lock_delete .as_ref() .is_some_and(|delete_opts| delete_opts.bypass_governance); if check_object_lock_for_deletion(bucket, obj_info, bypass_governance) .await .is_some() { return Err(StorageError::PrefixAccessDenied(bucket.to_string(), object.to_string())); } Ok(()) } fn set_disk_delete_creates_delete_marker(opts: &ObjectOptions) -> bool { opts.version_id.is_none() && opts.versioned && !opts.version_suspended } 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 storage_class_config = self.storage_class_config_snapshot(); 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 layout = resolve_write_layout( &storage_class_config, self.pool_index, disks.len(), self.default_parity_count, storage_class, opts.max_parity, )?; let fi = build_tiered_decommission_file_info(bucket, object, fi, layout); let write_quorum = layout.write_quorum; 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) } } #[derive(Debug, PartialEq, Eq)] struct ObjProps { successor_mod_time: Option, num_versions: usize, } impl Hash for ObjProps { fn hash(&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], data_errs_by_part: &HashMap>, ) -> (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 file_info_is_valid_for_metadata(fi) { valid_meta = fi.clone(); break; } } if !file_info_is_valid_for_metadata(&valid_meta) { 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.is_canonical_delete_marker() { 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]) -> (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]) -> 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]) -> String { let errs = errs .iter() .map(|err| { if let Some(err) = err { return err.to_string(); } "".to_string() }) .collect::>(); 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], parts_metadata: &mut [FileInfo], errs: &[Option], latest_meta: &FileInfo, filter_by_etag: bool, bucket: &str, object: &str, scan_mode: HealScanMode, ) -> disk::error::Result<(HashMap>, HashMap>)> { 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> = 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> = 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 !file_info_is_valid_for_metadata(meta) { // Since for majority of the cases erasure.Index matches with erasure.Distribution we can // consider the offline disks as consistent. continue; } if !meta.is_canonical_delete_marker() { 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 !file_info_is_valid_for_metadata(meta) { 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.is_canonical_delete_marker() && 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.is_canonical_delete_marker() || 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>, data_errs_by_part: &HashMap>, ) { 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, parts_errs: &[usize], meta: &FileInfo, latest_meta: &FileInfo, ) -> (bool, bool, Option) { 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.is_canonical_delete_marker() && !meta.is_remote() { if parts_errs.contains(&CHECK_PART_FILE_CORRUPT) { return (true, false, Some(DiskError::FileCorrupt)); } if parts_errs.contains(&CHECK_PART_FILE_NOT_FOUND) { return (true, false, Some(DiskError::PartMissingOrCorrupt)); } } (false, false, None) } async fn get_disks_info(disks: &[Option], eps: &[Endpoint]) -> Vec { 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, 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], 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], bucket: &str, prefix: &str) -> Vec> { 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> { 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()), // XXHash3/64/128 and SHA-512 (AWS 2026-04): s3s CompletePart has no typed // field to carry a client-supplied per-part value in the // CompleteMultipartUpload request, so accept the type with no double-check // value (the part was already verified server-side at UploadPart). This // mirrors the missing-value path of the five typed algorithms. Reject only // genuinely unset/invalid types. base if base.is_set() => Some(None), _ => 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, if_match: Option) -> 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 { storageclass::is_supported_write_class(storage_class) } /// 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::BLOCK_SIZE_V2; use crate::object_api::ObjectInfo; use crate::set_disk::read::GetObjectOutputKind; use crate::storage_api_contracts::{ heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart, namespace::NamespaceLocking as _, object::ObjectIO 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; use tokio::io::AsyncReadExt; #[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 { Err(LockError::internal("simulated offline client")) } async fn release(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result { Ok(false) } async fn refresh(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result { Ok(false) } async fn force_release(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result { Ok(false) } async fn check_status(&self, _lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result> { Ok(None) } async fn get_stats(&self) -> rustfs_lock::Result { 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, delay: Duration, } #[async_trait::async_trait] impl LockClient for DelayedBatchClient { async fn acquire_lock(&self, request: &rustfs_lock::LockRequest) -> rustfs_lock::Result { self.inner.acquire_lock(request).await } async fn acquire_locks_batch(&self, requests: &[rustfs_lock::LockRequest]) -> rustfs_lock::Result> { 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 { self.inner.release(lock_id).await } async fn release_locks_batch(&self, lock_ids: &[rustfs_lock::LockId]) -> rustfs_lock::Result> { self.inner.release_locks_batch(lock_ids).await } async fn refresh(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result { self.inner.refresh(lock_id).await } async fn force_release(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result { self.inner.force_release(lock_id).await } async fn check_status(&self, lock_id: &rustfs_lock::LockId) -> rustfs_lock::Result> { self.inner.check_status(lock_id).await } async fn get_stats(&self) -> rustfs_lock::Result { 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 { 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() ); } #[tokio::test] async fn test_rename_data_inline_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 = "inline-object"; let tmp_object = "tmp-inline-object"; let version_id = Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").expect("version id 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).await.expect("object 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 = Some(Bytes::from_static(b"old-inline")); old_fi.size = 10; 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 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 = Some(Bytes::from_static(b"new-inline")); new_fi.size = 10; 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); } #[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, ¤t, 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, ¤t, 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 = Arc::new(LocalClient::with_manager(manager)); let failing_client: Arc = 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 = Arc::new(LocalClient::with_manager(manager)); let failing_client: Arc = 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>) -> Arc { 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::>(); 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 } // backlog#1315: the memoized dirty scope must be byte-for-byte identical to // the ad-hoc scope the previous per-write construction produced, otherwise // dirty-disk keys diverge from the disk-cache keys and capacity counts drift. #[tokio::test] #[serial] async fn capacity_scope_memo_matches_adhoc_and_is_reused() { use rustfs_object_capacity::capacity_scope::drain_global_dirty_scopes; let (dir_a, disk_a) = make_single_local_disk().await; let (dir_b, disk_b) = make_single_local_disk().await; let disks = vec![Some(disk_a), Some(disk_b)]; let set = make_set_disks_with(disks.clone()).await; let expected = capacity_scope_from_disks(&disks); let first = set.capacity_scope(&disks); assert_eq!(*first, expected, "memoized scope must equal the ad-hoc per-disk construction bit-for-bit"); // Second call on a fully-resolved set returns the very same Arc (no new // allocation): proving steady-state writes do not rebuild String/HashSet. let second = set.capacity_scope(&disks); assert!(Arc::ptr_eq(&first, &second), "steady-state scope must reuse the cached Arc"); let _ = drain_global_dirty_scopes(); drop((dir_a, dir_b)); } // backlog#1315: the global registry mutex must be upgraded only on the first // write of each generation; steady-state writes skip it. Reverting the // generation skip makes the upgrade count grow per write and fails this test. #[tokio::test] #[serial] async fn record_capacity_scope_upgrades_registry_once_per_generation() { use rustfs_object_capacity::capacity_scope::{drain_global_dirty_scopes, global_dirty_upgrade_count}; let (dir_a, disk_a) = make_single_local_disk().await; let (dir_b, disk_b) = make_single_local_disk().await; let disks = vec![Some(disk_a), Some(disk_b)]; let set = make_set_disks_with(disks.clone()).await; // Start from a clean registry generation. let _ = drain_global_dirty_scopes(); let before = global_dirty_upgrade_count(); // First write of this generation upgrades the registry exactly once. set.record_capacity_scope_if_needed(None, &disks); assert_eq!( global_dirty_upgrade_count(), before + 1, "first write of a generation must upgrade the global registry" ); // Subsequent writes in the same generation must not touch the mutex. for _ in 0..16 { set.record_capacity_scope_if_needed(None, &disks); } assert_eq!( global_dirty_upgrade_count(), before + 1, "steady-state writes must reuse the generation mark, not re-upgrade" ); // A drain advances the generation; the next write must re-mark so the // disks it wrote are captured by the following refresh (no lost update). let drained = drain_global_dirty_scopes(); assert_eq!(drained.len(), 2, "both set disks must have been recorded dirty"); set.record_capacity_scope_if_needed(None, &disks); assert_eq!( global_dirty_upgrade_count(), before + 2, "the first write after a drain must re-upgrade the registry" ); let _ = drain_global_dirty_scopes(); drop((dir_a, dir_b)); } // backlog#1315: an offline slot must not force the per-write slow path, and // the resolved scope must still cover every online disk. #[tokio::test] #[serial] async fn capacity_scope_tolerates_offline_slot_without_reallocating() { use rustfs_object_capacity::capacity_scope::drain_global_dirty_scopes; let (dir_a, disk_a) = make_single_local_disk().await; // Slot 1 is permanently offline (None); the set never resolves it. let disks = vec![Some(disk_a), None]; let set = make_set_disks_with(disks.clone()).await; let first = set.capacity_scope(&disks); assert_eq!(first.disks.len(), 1, "only the online disk contributes to the scope"); // Even though the set is not "complete" (slot 1 unresolved), repeated // writes with the same online set reuse the cached Arc via the // no-unresolved-slot fast path. let second = set.capacity_scope(&disks); assert!( Arc::ptr_eq(&first, &second), "a stable online subset must reuse the cached Arc even with an offline slot" ); let _ = drain_global_dirty_scopes(); drop(dir_a); } // 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" ); } // Build an `xl.meta` under `object_dir` whose versions reference `data_dirs` // (one Object version per data dir, each with its own version id). async fn write_object_meta_with_data_dirs(object_dir: &std::path::Path, bucket: &str, object: &str, data_dirs: &[Uuid]) { fs::create_dir_all(object_dir).await.expect("object dir should be created"); let mut meta = FileMeta::default(); for data_dir in data_dirs { let mut fi = FileInfo::new(&format!("{bucket}/{object}"), 1, 1); fi.name = object.to_string(); fi.version_id = Some(Uuid::new_v4()); fi.data_dir = Some(*data_dir); fi.size = 1; fi.mod_time = Some(OffsetDateTime::now_utc()); meta.add_version(fi).expect("metadata should accept file info"); } let buf = meta.marshal_msg().expect("metadata should encode"); fs::write(object_dir.join(STORAGE_FORMAT_FILE), buf) .await .expect("metadata should be written"); } // #3231/#3191: a data dir on disk that no version references (a pre-#3510 // unversioned-overwrite leak) must be reclaimed, leaving the live one intact. #[tokio::test] async fn reclaim_orphan_data_dirs_removes_unreferenced_dir() { let (dir, disk) = make_single_local_disk().await; let root = dir.path(); let live = Uuid::new_v4(); let orphan = Uuid::new_v4(); let object_dir = root.join("bucket").join("obj"); write_object_meta_with_data_dirs(&object_dir, "bucket", "obj", &[live]).await; fs::create_dir_all(object_dir.join(live.to_string())) .await .expect("live data dir should be created"); fs::write(object_dir.join(live.to_string()).join("part.1"), b"live") .await .expect("live part should be written"); fs::create_dir_all(object_dir.join(orphan.to_string())) .await .expect("orphan data dir should be created"); fs::write(object_dir.join(orphan.to_string()).join("part.1"), b"stale") .await .expect("orphan part should be written"); let set = make_set_disks_with(vec![Some(disk)]).await; let removed = set .reclaim_orphan_data_dirs("bucket", "obj") .await .expect("reclaim should succeed"); assert_eq!(removed, 1, "exactly the unreferenced data dir should be removed"); assert!(object_dir.join(live.to_string()).exists(), "referenced data dir must be preserved"); assert!(!object_dir.join(orphan.to_string()).exists(), "orphaned data dir must be removed"); assert!(object_dir.join(STORAGE_FORMAT_FILE).exists(), "metadata must be preserved"); } // Nothing to reclaim when every physical data dir is still referenced. #[tokio::test] async fn reclaim_orphan_data_dirs_keeps_referenced_dir() { let (dir, disk) = make_single_local_disk().await; let root = dir.path(); let live = Uuid::new_v4(); let object_dir = root.join("bucket").join("obj"); write_object_meta_with_data_dirs(&object_dir, "bucket", "obj", &[live]).await; fs::create_dir_all(object_dir.join(live.to_string())) .await .expect("live data dir should be created"); let set = make_set_disks_with(vec![Some(disk)]).await; let removed = set .reclaim_orphan_data_dirs("bucket", "obj") .await .expect("reclaim should succeed"); assert_eq!(removed, 0, "no data dir should be removed"); assert!(object_dir.join(live.to_string()).exists(), "referenced data dir must be preserved"); } // Fail closed: a data dir present without a readable xl.meta is degraded, and // must never be removed (a heal has to run first). #[tokio::test] async fn reclaim_orphan_data_dirs_aborts_when_meta_missing() { let (dir, disk) = make_single_local_disk().await; let root = dir.path(); let stray = Uuid::new_v4(); let object_dir = root.join("bucket").join("obj"); fs::create_dir_all(object_dir.join(stray.to_string())) .await .expect("data dir should be created"); fs::write(object_dir.join(stray.to_string()).join("part.1"), b"data") .await .expect("part should be written"); let set = make_set_disks_with(vec![Some(disk)]).await; let removed = set .reclaim_orphan_data_dirs("bucket", "obj") .await .expect("reclaim should succeed"); assert_eq!(removed, 0, "must not remove data dirs when metadata is absent"); assert!( object_dir.join(stray.to_string()).exists(), "degraded object's data dir must be preserved" ); } // Cross-replica union: a data dir referenced by ANOTHER disk's xl.meta must be // kept even where the local replica does not name it. #[tokio::test] async fn reclaim_orphan_data_dirs_keeps_dir_referenced_by_other_replica() { let (dir0, disk0) = make_single_local_disk().await; let (dir1, disk1) = make_single_local_disk().await; let shared = Uuid::new_v4(); // disk0: meta references only a different dir, but physically holds `shared`. let other = Uuid::new_v4(); let obj0 = dir0.path().join("bucket").join("obj"); write_object_meta_with_data_dirs(&obj0, "bucket", "obj", &[other]).await; fs::create_dir_all(obj0.join(other.to_string())) .await .expect("dir should be created"); fs::create_dir_all(obj0.join(shared.to_string())) .await .expect("dir should be created"); // disk1: meta references `shared`. let obj1 = dir1.path().join("bucket").join("obj"); write_object_meta_with_data_dirs(&obj1, "bucket", "obj", &[shared]).await; fs::create_dir_all(obj1.join(shared.to_string())) .await .expect("dir should be created"); let set = make_set_disks_with(vec![Some(disk0), Some(disk1)]).await; let removed = set .reclaim_orphan_data_dirs("bucket", "obj") .await .expect("reclaim should succeed"); assert_eq!(removed, 0, "a dir referenced by any replica must be kept"); assert!(obj0.join(shared.to_string()).exists(), "cross-referenced data dir must survive"); assert!(obj0.join(other.to_string()).exists(), "locally referenced data dir must survive"); } // backlog#898 A5: old == committed data dir => anti-misdelete guard skips the // whole cleanup; the live (just-committed) dir must be left untouched. #[tokio::test] async fn commit_rename_data_dir_skips_delete_when_old_equals_committed_dir() { let (dir, disk) = make_single_local_disk().await; let root = dir.path(); let bucket = "bucket"; let object = "object"; fs::create_dir_all(root.join(bucket)) .await .expect("bucket volume should be created"); let same_dir = Uuid::parse_str("55555555-5555-5555-5555-555555555555").expect("dir should parse"); let data_path = root.join(bucket).join(object).join(same_dir.to_string()); fs::create_dir_all(&data_path).await.expect("data dir should be created"); fs::write(data_path.join("part.1"), b"live") .await .expect("live part should be written"); let set = make_set_disks_with(vec![Some(disk.clone())]).await; let cleanup = set .commit_rename_data_dir(&[Some(disk.clone())], bucket, object, &same_dir.to_string(), &same_dir.to_string(), 1) .await; assert_eq!(cleanup.attempted, 0, "guard must skip: no delete may be issued"); assert!(cleanup.unreclaimed_disks.is_empty()); assert!(!cleanup.has_residue()); assert!(data_path.join("part.1").exists(), "committed data dir must NOT be deleted"); } // backlog#898 A5b: old != committed and old dir exists => normal reclaim; the // dereferenced old dir is physically removed and the receipt reports success. #[tokio::test] async fn commit_rename_data_dir_reclaims_distinct_old_dir() { let (dir, disk) = make_single_local_disk().await; let root = dir.path(); let bucket = "bucket"; let object = "object"; fs::create_dir_all(root.join(bucket)) .await .expect("bucket volume should be created"); let old_dir = Uuid::parse_str("11111111-1111-1111-1111-111111111111").expect("old dir should parse"); let new_dir = Uuid::parse_str("22222222-2222-2222-2222-222222222222").expect("new dir should parse"); let old_path = root.join(bucket).join(object).join(old_dir.to_string()); fs::create_dir_all(&old_path).await.expect("old data dir should be created"); fs::write(old_path.join("part.1"), b"stale") .await .expect("stale part should be written"); let set = make_set_disks_with(vec![Some(disk.clone())]).await; let cleanup = set .commit_rename_data_dir(&[Some(disk.clone())], bucket, object, &old_dir.to_string(), &new_dir.to_string(), 1) .await; assert_eq!(cleanup.attempted, 1); assert_eq!(cleanup.reclaimed, 1); assert!(!cleanup.has_residue()); assert!(!cleanup.below_quorum); assert!(!old_path.exists(), "dereferenced old data dir must be physically removed"); } // backlog#898 group B (end-to-end): a real overwrite whose post-commit old // data dir cleanup is forced to fail (via the test-only fault seam) must // still return 200 with the new ObjectInfo — no false-negative ACK. #[tokio::test] async fn put_object_overwrite_returns_ok_when_old_data_dir_cleanup_fails() { use crate::set_disk::core::io_primitives::cleanup_fault_injection; let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-cleanup-fault"; let object = "object-below-quorum-cleanup"; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); // v1: establishes an old data dir that the overwrite will try to reclaim. let mut reader = PutObjReader::from_vec(b"hello".to_vec()); set_disks .put_object( bucket, object, &mut reader, &ObjectOptions { no_lock: true, ..ObjectOptions::default() }, ) .await .expect("first write should succeed"); // Force the old-data-dir cleanup delete to fail on disk 0 during the // overwrite. rename_data still commits; only the GC of the dereferenced // old dir fails, which must NOT turn the PUT into a 503. let _fault = cleanup_fault_injection::fail_cleanup_on(object, &[0]); let mut reader = PutObjReader::from_vec(b"goodbye!!".to_vec()); let oi = set_disks .put_object( bucket, object, &mut reader, &ObjectOptions { no_lock: true, ..ObjectOptions::default() }, ) .await .expect("overwrite must return Ok even when old-data-dir cleanup fails"); assert_eq!(oi.size, 9, "returned ObjectInfo must reflect the new committed write"); // The committed new version must be readable with the new size. let read_back = set_disks .get_object_info(bucket, object, &ObjectOptions::default()) .await .expect("committed object must be readable after a failed cleanup"); assert_eq!(read_back.size, 9, "HEAD must observe the new version, not stale metadata"); } #[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 = Arc::new(LocalClient::with_manager(manager1.clone())); let client2: Arc = 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::(), 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 = Arc::new(LocalClient::with_manager(manager.clone())); let failing_client: Arc = 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::(), 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 = Arc::new(LocalClient::with_manager(manager_fast_1)); let client_fast_2: Arc = Arc::new(LocalClient::with_manager(manager_fast_2)); let client_fast_3: Arc = Arc::new(LocalClient::with_manager(manager_fast_3)); let client_slow: Arc = 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 = Arc::new(LocalClient::with_manager(manager_fast)); let client_fail_1: Arc = Arc::new(FailingClient); let client_fail_2: Arc = Arc::new(FailingClient); let client_slow: Arc = 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::(), 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(×, 2), Some(now)); let times2 = vec![Some(now), Some(later), Some(later)]; assert_eq!(SetDisks::common_time(×2, 2), Some(later)); let times_with_none = vec![Some(now), None, Some(now)]; assert_eq!(SetDisks::common_time(×_with_none, 2), Some(now)); let times = vec![None, None, None]; assert_eq!(SetDisks::common_time(×, 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(×); assert_eq!(time, Some(now)); assert_eq!(count, 2); let times = vec![None, None, None]; let (time, count) = SetDisks::common_time_and_occurrence(×); 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, °raded_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, block_size: 4, 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, block_size: 4, 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, block_size: 4, 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 delete_markers_participate_in_four_disk_metadata_quorum_without_erasure_geometry() { let marker = FileInfo { name: "bucket/deleted".to_string(), deleted: true, version_id: Some(Uuid::new_v4()), mod_time: Some(OffsetDateTime::now_utc()), ..Default::default() }; let parts_metadata = vec![marker; 4]; let errs = vec![None; 4]; assert!(parts_metadata.iter().all(file_info_is_valid_for_metadata)); assert!(parts_metadata.iter().all(|metadata| !metadata.is_valid())); assert_eq!(SetDisks::list_object_parities(&parts_metadata, &errs), vec![2; 4]); assert_eq!( SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 2) .expect("four matching delete markers must reach metadata quorum"), (2, 3) ); } #[test] fn metadata_boundary_does_not_relax_non_delete_or_malformed_delete_metadata() { assert!(!file_info_is_valid_for_metadata(&FileInfo::default())); let mut transitioned = FileInfo::new("bucket/transitioned", 2, 2); transitioned.erasure.index = 1; transitioned.transition_status = TRANSITION_COMPLETE.to_string(); assert!(file_info_is_valid_for_metadata(&transitioned)); transitioned.erasure = ErasureInfo::default(); assert!( !file_info_is_valid_for_metadata(&transitioned), "transition state must not relax local erasure validation" ); let mut purge_pending = FileInfo::new("bucket/purge-pending", 2, 2); purge_pending.erasure.index = 1; purge_pending.deleted = true; purge_pending.parts.push(ObjectPartInfo { number: 1, ..Default::default() }); assert!( file_info_is_valid_for_metadata(&purge_pending), "purge-pending payload metadata must retain its valid erasure vote" ); assert!(!purge_pending.is_canonical_delete_marker()); let mut malformed_marker = FileInfo { deleted: true, ..Default::default() }; malformed_marker.parts = vec![ ObjectPartInfo { number: 1, ..Default::default() }, ObjectPartInfo { number: 1, ..Default::default() }, ]; assert!(!file_info_is_valid_for_metadata(&malformed_marker)); } #[test] fn purge_pending_payload_uses_its_erasure_parity_for_metadata_quorum() { let version_id = Uuid::new_v4(); let mod_time = OffsetDateTime::now_utc(); let parts_metadata = (1..=6) .map(|disk_index| { let mut purge_pending = FileInfo::new("bucket/purge-pending", 5, 1); purge_pending.name = "bucket/purge-pending".to_string(); purge_pending.version_id = Some(version_id); purge_pending.mod_time = Some(mod_time); purge_pending.size = 1; purge_pending.deleted = true; purge_pending.erasure.index = disk_index; purge_pending.add_object_part(1, "part-etag-1".to_string(), 1, None, 1, None, None); purge_pending }) .collect::>(); let errs = vec![None; 6]; assert!(parts_metadata.iter().all(file_info_is_valid_for_metadata)); assert!(parts_metadata.iter().all(|metadata| !metadata.is_canonical_delete_marker())); assert_eq!(SetDisks::list_object_parities(&parts_metadata, &errs), vec![1; 6]); assert_eq!( SetDisks::object_quorum_from_meta(&parts_metadata, &errs, 3) .expect("purge-pending payload should retain its EC:1 quorum"), (5, 5) ); } #[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, _, reason) = should_heal_object_on_disk(&None, &[CHECK_PART_FILE_CORRUPT], &meta, &latest_meta); assert!(should_heal); assert_eq!(reason, Some(DiskError::FileCorrupt)); } #[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> { Poll::Pending } fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 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::(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> { Poll::Pending } fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 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::(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("")); 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("")); 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 } fn rename_versions_signature(first_key: u8, version_count: usize) -> Vec { let mut signature = vec![0; version_count * 16]; signature[7] = first_key; signature } // backlog#1321: `classify_rename_convergence` is the single decision that // replaced the old `Option>::is_some()` heal gate. These are the // revert-fails white-box cases behind the issue acceptance matrix — if the // decision regressed to "a signature exists => needs heal", the healthy // 4/4 and 8/8 cases below would flip from `AllSuccessIdentical` to a // heal-worthy variant and fail. #[test] fn test_classify_rename_convergence_healthy_identical_needs_no_heal() { use crate::set_disk::core::io_primitives::RenameConvergence; let sig = rename_versions_signature(1, 3); // Healthy 4/4: every disk committed with an identical, known signature. let disk_versions = vec![Some(sig.clone()), Some(sig.clone()), Some(sig.clone()), Some(sig.clone())]; let errs = vec![None, None, None, None]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::AllSuccessIdentical); assert!(!convergence.needs_heal(), "a fully converged healthy MPU must not enqueue heal"); // Healthy 8/8: same property at a wider set width. let disk_versions = vec![Some(sig); 8]; let errs = vec![None; 8]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::AllSuccessIdentical); assert!(!convergence.needs_heal()); } #[test] fn test_classify_rename_convergence_three_agree_one_diverges_heals() { use crate::set_disk::core::io_primitives::RenameConvergence; let common = rename_versions_signature(1, 2); let odd = rename_versions_signature(2, 2); // 3-same, 1-divergent, all committed: reconcile the odd replica. let disk_versions = vec![Some(common.clone()), Some(common.clone()), Some(common), Some(odd)]; let errs = vec![None, None, None, None]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::SignatureDivergent); assert!(convergence.needs_heal()); } #[test] fn test_classify_rename_convergence_failed_or_offline_disk_heals() { use crate::set_disk::core::io_primitives::RenameConvergence; let sig = rename_versions_signature(1, 2); // 1 disk failed/offline while the rest committed identically: past the // write-quorum gate this is a `PartialCommit` — a replica is missing. let disk_versions = vec![Some(sig.clone()), Some(sig.clone()), Some(sig), None]; let errs = vec![None, None, None, Some(DiskError::DiskNotFound)]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::PartialCommit); assert!(convergence.needs_heal()); } #[test] fn test_classify_rename_convergence_no_common_quorum_heals() { use crate::set_disk::core::io_primitives::RenameConvergence; // No signature holds a majority (2/2 split), every disk committed: // divergence with no common quorum still reconciles via heal. let a = rename_versions_signature(1, 1); let b = rename_versions_signature(2, 1); let disk_versions = vec![Some(a.clone()), Some(a), Some(b.clone()), Some(b)]; let errs = vec![None, None, None, None]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::SignatureDivergent); assert!(convergence.needs_heal()); } #[test] fn test_classify_rename_convergence_over_ten_versions_by_success() { use crate::set_disk::core::io_primitives::RenameConvergence; // >10 versions: every disk deliberately omits the signature (`None`). // All committed => `Unknown` => scanner-backstopped, no self-enqueue. let disk_versions = vec![None, None, None, None]; let errs = vec![None, None, None, None]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::Unknown); assert!( !convergence.needs_heal(), ">10-version healthy commit relies on the scanner, not self-enqueue" ); // Same >10-version shape but with a failed disk: a failure is // conservative-heal regardless of signatures being unavailable. let errs = vec![None, None, None, Some(DiskError::FileCorrupt)]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::PartialCommit); assert!(convergence.needs_heal()); } #[test] fn test_classify_rename_convergence_mixed_signed_unsigned_heals() { use crate::set_disk::core::io_primitives::RenameConvergence; // A committed replica with <=10 versions (signed) alongside one with // >10 versions (unsigned) is itself a version-count divergence. let sig = rename_versions_signature(1, 2); let disk_versions = vec![Some(sig.clone()), Some(sig.clone()), Some(sig), None]; let errs = vec![None, None, None, None]; let convergence = SetDisks::classify_rename_convergence(&disk_versions, &errs); assert_eq!(convergence, RenameConvergence::SignatureDivergent); assert!(convergence.needs_heal()); } #[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 layout = WriteLayout::from_parity(16, 4).expect("tiered write layout should be valid"); let updated = build_tiered_decommission_file_info("bucket", "object", &original, layout); 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!(layout.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"); } #[tokio::test] async fn test_check_object_lock_delete_blocks_compliance_version_delete() { let retain_until = OffsetDateTime::now_utc() + Duration::from_secs(60 * 60 * 24 * 60); 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(), retain_until.format(&time::format_description::well_known::Rfc3339).unwrap(), ); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let opts = ObjectOptions { version_id: Some(Uuid::new_v4().to_string()), versioned: true, ..Default::default() }; let err = check_object_lock_delete("bucket", "object", &obj_info, &opts) .await .expect_err("COMPLIANCE retention must block explicit version deletion"); assert!(matches!(err, StorageError::PrefixAccessDenied(_, _))); } #[tokio::test] async fn test_check_object_lock_delete_allows_versioned_delete_marker_creation() { let retain_until = OffsetDateTime::now_utc() + Duration::from_secs(60 * 60 * 24 * 60); 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(), retain_until.format(&time::format_description::well_known::Rfc3339).unwrap(), ); let obj_info = ObjectInfo { user_defined: Arc::new(user_defined), ..Default::default() }; let opts = ObjectOptions { version_id: None, versioned: true, version_suspended: false, ..Default::default() }; check_object_lock_delete("bucket", "object", &obj_info, &opts) .await .expect("versioned delete marker creation should not delete the locked version"); } // backlog#929 (HP-8): the delete_objects per-object stat is gated on the // bucket object-lock configuration. Lock-enabled buckets (either legacy // lock_enabled flag or an enabled ObjectLockConfiguration) and unknown // metadata must keep the #4297 locked-stat path; only buckets that // provably have no Object Lock may skip it. #[test] fn test_object_lock_delete_check_required_skips_plain_buckets() { let bm = crate::bucket::metadata::BucketMetadata::new("plain-bucket"); assert!(!object_lock_delete_check_required(Some(&bm))); } #[test] fn test_object_lock_delete_check_required_keeps_legacy_lock_enabled_buckets() { let mut bm = crate::bucket::metadata::BucketMetadata::new("legacy-lock-bucket"); bm.lock_enabled = true; assert!(object_lock_delete_check_required(Some(&bm))); } #[test] fn test_object_lock_delete_check_required_keeps_object_lock_config_buckets() { use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled}; let mut bm = crate::bucket::metadata::BucketMetadata::new("lock-config-bucket"); bm.object_lock_config = Some(ObjectLockConfiguration { object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)), ..Default::default() }); assert!(object_lock_delete_check_required(Some(&bm))); } #[test] fn test_object_lock_delete_check_required_fails_closed_without_metadata() { assert!(object_lock_delete_check_required(None)); } #[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() { assert!(is_valid_storage_class(storageclass::STANDARD)); assert!(is_valid_storage_class(storageclass::RRS)); assert!(!is_valid_storage_class(storageclass::STANDARD_IA)); assert!(!is_valid_storage_class("INVALID")); } #[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()))); // AWS 2026-04 additional algorithms have no CompletePart field, so they are // accepted with no double-check value (verified at UploadPart) — Some(None), // NOT the outer None that would fail the multipart completion (#1261). for ct in [ rustfs_rio::ChecksumType::XXHASH3, rustfs_rio::ChecksumType::XXHASH64, rustfs_rio::ChecksumType::XXHASH128, rustfs_rio::ChecksumType::SHA512, rustfs_rio::ChecksumType::MD5, ] { assert_eq!(complete_part_checksum(&CompletePart::default(), ct), Some(None), "{ct:?}"); } // Genuinely unset / invalid types must still be rejected (outer None). assert_eq!(complete_part_checksum(&CompletePart::default(), rustfs_rio::ChecksumType::NONE), None); assert_eq!(complete_part_checksum(&CompletePart::default(), rustfs_rio::ChecksumType::INVALID), None); } 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 inline_fast_path_rejects_part_number_requests() { let (mut object_info, mut fi, opts) = direct_memory_test_metadata(1024); object_info.inlined = true; fi.data = Some(Bytes::from(vec![0; 1024])); assert!(should_use_inline_fast_path(&None, &object_info, &fi, &opts)); let mut part_opts = opts; part_opts.part_number = Some(1); assert!(!should_use_inline_fast_path(&None, &object_info, &fi, &part_opts)); } #[test] fn small_object_direct_memory_decision_reports_bounded_reasons() { let (object_info, fi, opts) = direct_memory_test_metadata(1024); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &object_info, &fi, &opts, false, 128 * 1024), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Disabled) ); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &object_info, &fi, &opts, true, 0), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::ThresholdZero) ); assert_eq!( get_small_object_direct_memory_decision_with_threshold( &Some(HTTPRangeSpec { start: 0, end: 10, is_suffix_length: false, }), &object_info, &fi, &opts, true, 128 * 1024 ), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Range) ); let mut versioned_opts = opts.clone(); versioned_opts.versioned = true; assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &object_info, &fi, &versioned_opts, true, 128 * 1024), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Versioned) ); let mut encrypted = object_info.clone(); Arc::make_mut(&mut encrypted.user_defined).insert("x-amz-server-side-encryption".to_string(), "AES256".to_string()); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &encrypted, &fi, &opts, true, 128 * 1024), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Encrypted) ); let mut remote = object_info.clone(); remote.transitioned_object.status = TRANSITION_COMPLETE.to_string(); remote.transitioned_object.tier = "remote-tier".to_string(); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &remote, &fi, &opts, true, 128 * 1024), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::Remote) ); let mut multipart = object_info.clone(); multipart.parts = Arc::new(vec![ObjectPartInfo::default(), ObjectPartInfo::default()]); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &multipart, &fi, &opts, true, 128 * 1024), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::ObjectInfoMultipart) ); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &object_info, &fi, &opts, true, 512), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::AboveThreshold) ); let mut size_mismatch = object_info.clone(); size_mismatch.size += 1; assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &size_mismatch, &fi, &opts, true, 128 * 1024), GetDirectMemoryDecision::Fallback(GetDirectMemoryFallbackReason::SizeMismatch) ); assert_eq!( get_small_object_direct_memory_decision_with_threshold(&None, &object_info, &fi, &opts, true, 128 * 1024), GetDirectMemoryDecision::Use { object_size: 1024 } ); } #[test] fn direct_memory_fallback_metric_labels_are_stable() { assert_eq!(GetDirectMemoryFallbackReason::Disabled.as_str(), "disabled"); assert_eq!(GetDirectMemoryFallbackReason::ThresholdZero.as_str(), "threshold_zero"); assert_eq!(GetDirectMemoryFallbackReason::Range.as_str(), "range"); assert_eq!(GetDirectMemoryFallbackReason::PartNumber.as_str(), "part_number"); assert_eq!(GetDirectMemoryFallbackReason::VersionId.as_str(), "version_id"); assert_eq!(GetDirectMemoryFallbackReason::Versioned.as_str(), "versioned"); assert_eq!(GetDirectMemoryFallbackReason::VersionSuspended.as_str(), "version_suspended"); assert_eq!(GetDirectMemoryFallbackReason::InclFreeVersions.as_str(), "incl_free_versions"); assert_eq!(GetDirectMemoryFallbackReason::SkipFreeVersion.as_str(), "skip_free_version"); assert_eq!(GetDirectMemoryFallbackReason::DataMovement.as_str(), "data_movement"); assert_eq!(GetDirectMemoryFallbackReason::RawDataMovementRead.as_str(), "raw_data_movement_read"); assert_eq!(GetDirectMemoryFallbackReason::DeleteMarker.as_str(), "delete_marker"); assert_eq!(GetDirectMemoryFallbackReason::MetadataOnly.as_str(), "metadata_only"); assert_eq!(GetDirectMemoryFallbackReason::VersionOnly.as_str(), "version_only"); assert_eq!(GetDirectMemoryFallbackReason::Encrypted.as_str(), "encrypted"); assert_eq!(GetDirectMemoryFallbackReason::Compressed.as_str(), "compressed"); assert_eq!(GetDirectMemoryFallbackReason::Remote.as_str(), "remote"); assert_eq!(GetDirectMemoryFallbackReason::ObjectInfoMultipart.as_str(), "object_info_multipart"); assert_eq!(GetDirectMemoryFallbackReason::FileInfoMultipart.as_str(), "file_info_multipart"); assert_eq!(GetDirectMemoryFallbackReason::InvalidSize.as_str(), "invalid_size"); assert_eq!(GetDirectMemoryFallbackReason::SizeMismatch.as_str(), "size_mismatch"); assert_eq!(GetDirectMemoryFallbackReason::AboveThreshold.as_str(), "above_threshold"); } #[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, 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::>(), ["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, GetObjectOutputKind::HttpResponse, 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]); } #[tokio::test] async fn multipart_reads_stream_all_parts_with_setup_prefetch() { 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"; // Three parts with distinct fill bytes so cross-part ordering bugs and // prefetch boundary mistakes surface as content mismatches // (backlog#870 exercises the prefetch hit path for parts 2 and 3). let parts: Vec> = vec![ vec![b'a'; 2 * 1024 * 1024 + 111], vec![b'b'; 1024 * 1024 + 17], vec![b'c'; 3 * 1024 * 1024 + 923], ]; let total_size: usize = parts.iter().map(|part| part.len()).sum(); 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 = total_size as i64; for (index, part) in parts.iter().enumerate() { fi.add_object_part(index + 1, String::new(), part.len(), None, part.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, ); for (index, payload) in parts.iter().enumerate() { let part_number = index + 1; let shard_path = format!("{object}/{data_dir}/part.{part_number}"); let checksum_info = fi.erasure.get_checksum_info(part_number); 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(64 * 1024); 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, 0, total_size as i64, &mut writer, GetObjectOutputKind::HttpResponse, 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("all part bytes should be readable"); read_task .await .expect("read task should complete") .expect("multipart read should succeed"); let expected: Vec = parts.concat(); assert_eq!(out.len(), expected.len(), "all parts should be streamed"); assert_eq!(out, expected, "part contents and ordering must survive setup prefetch"); } #[test] fn parts_after_marker_uses_marker_position() { let part_numbers = (1..=1002).collect::>(); 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 { make_local_bucket_test_set_disks_with_drive_count(2).await } async fn make_local_bucket_test_set_disks_with_drive_count(drive_count: usize) -> Arc { let format = FormatV3::new(1, drive_count); let mut endpoints = Vec::new(); let mut disks = Vec::new(); for disk_idx in 0..drive_count { 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)); } let set_disks = SetDisks::new( "test-owner".to_string(), Arc::new(RwLock::new(disks)), drive_count, drive_count / 2, 0, 0, endpoints, format, Vec::new(), ) .await; set_disks.set_test_storage_class_config( storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[drive_count]) .expect("test storage class should resolve for the local drive count"), ); set_disks } async fn make_local_bucket_test_set_disks_with_missing_format() -> Arc { 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 { no_lock: true, ..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_put_get_delete_restores_large_object_and_fails_closed_after_delete() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-object-roundtrip"; let object = "nested/object.bin"; let payload = (0..(BLOCK_SIZE_V2 + 17)).map(|idx| (idx % 251) as u8).collect::>(); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(payload.clone()); let written = set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("large object should be written"); assert_eq!(written.size, payload.len() as i64); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("large object reader should open"); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("large object should stream"); assert_eq!(restored, payload); let deleted = set_disks .delete_object(bucket, object, opts.clone()) .await .expect("object delete should succeed"); assert_eq!(deleted.name, object); let err = match set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("deleted object must not be readable"), Err(err) => err, }; assert!( is_err_object_not_found(&err), "deleted object read must fail closed with object-not-found, got {err:?}" ); } #[tokio::test] async fn set_level_batched_large_put_get_restores_body() { const BATCHED_LARGE_SIZE: usize = 64 * 1024 * 1024; let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-batched-large-object"; let object = "large/batched.bin"; let payload = (0..BATCHED_LARGE_SIZE) .map(|idx| ((idx as u64).wrapping_mul(31).wrapping_add(17) % 251) as u8) .collect::>(); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(payload.clone()); let written = set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("batched-large object should be written"); assert_eq!(written.size, payload.len() as i64); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("batched-large object reader should open"); let mut restored = Vec::with_capacity(payload.len()); get_reader .stream .read_to_end(&mut restored) .await .expect("batched-large object should stream"); assert_eq!(restored, payload); } #[tokio::test] async fn set_level_put_object_fails_closed_when_writer_quorum_is_unavailable() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-put-writer-quorum"; let object = "object.txt"; let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created before disk loss"); { let mut disks = set_disks.disks.write().await; disks[1] = None; } let mut reader = PutObjReader::from_vec(b"quorum guarded body".to_vec()); let err = set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect_err("missing writer quorum must fail the put"); assert!( matches!(err, Error::ErasureWriteQuorum | Error::InsufficientWriteQuorum(_, _)), "expected write quorum failure, got {err:?}" ); let read_err = match set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("failed put must not leave a readable object"), Err(err) => err, }; assert!( is_err_object_not_found(&read_err) || matches!(read_err, Error::ErasureReadQuorum), "failed put must fail closed on read, got {read_err:?}" ); } #[tokio::test] async fn set_level_put_object_short_reader_fails_closed_across_write_paths() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-put-short-reader"; let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let cases = [ ("single-block", b"short body".to_vec(), 32), ("pipeline", b"pipeline short body".to_vec(), BLOCK_SIZE_V2 as i64 + 1), ("batched-large", b"batched short body".to_vec(), 64 * 1024 * 1024), ]; for (name, payload, declared_size) in cases { let object = format!("{name}/object.txt"); let hash_reader = HashReader::from_stream(Cursor::new(payload), declared_size, declared_size, None, None, false) .expect("test reader should be constructed"); let mut reader = PutObjReader::new(hash_reader); let err = set_disks .put_object(bucket, &object, &mut reader, &opts) .await .expect_err("short reader must fail the put"); let err_text = format!("{err:?}"); assert!( err_text.contains("UnexpectedEof") || err_text.contains("IncompleteBody"), "{name} short reader should fail with an EOF/incomplete-body error, got {err:?}" ); let read_err = match set_disks .get_object_reader(bucket, &object, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("{name} short failed put must not leave a readable object"), Err(err) => err, }; assert!( is_err_object_not_found(&read_err) || matches!(read_err, Error::ErasureReadQuorum), "{name} short failed put must fail closed on read, got {read_err:?}" ); } } #[tokio::test] async fn set_level_get_restores_body_when_one_shard_is_missing_after_write() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-read-repair-missing-shard"; let object = "object.bin"; let payload = (0..(BLOCK_SIZE_V2 + 211)) .map(|idx| ((idx * 13) % 251) as u8) .collect::>(); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(payload.clone()); set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("object should be written before shard loss"); { let mut disks = set_disks.disks.write().await; disks[1] = None; } let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("object should remain readable with one missing shard"); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("degraded read should stream"); assert_eq!(restored, payload); } #[tokio::test] async fn set_level_overwrite_restores_new_body_and_cleans_old_data_dir() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-object-overwrite"; let object = "nested/object.bin"; let first_payload = (0..(BLOCK_SIZE_V2 + 31)).map(|idx| (idx % 239) as u8).collect::>(); let second_payload = (0..(BLOCK_SIZE_V2 + 97)) .map(|idx| ((idx * 7) % 251) as u8) .collect::>(); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut first_reader = PutObjReader::from_vec(first_payload); set_disks .put_object(bucket, object, &mut first_reader, &opts) .await .expect("first object body should be written"); let mut second_reader = PutObjReader::from_vec(second_payload.clone()); let second = set_disks .put_object(bucket, object, &mut second_reader, &opts) .await .expect("overwrite body should commit and clean the old data dir"); assert_eq!(second.size, second_payload.len() as i64); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("overwritten object reader should open"); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("overwritten object should stream"); assert_eq!(restored, second_payload); } #[tokio::test] async fn set_level_write_preconditions_fail_closed_and_allow_matching_etags() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-write-preconditions"; let object = "object.txt"; let write_opts = ObjectOptions { no_lock: true, preserve_etag: Some("conditional-etag".to_string()), ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(b"conditional body".to_vec()); set_disks .put_object(bucket, object, &mut reader, &write_opts) .await .expect("object should be written with deterministic etag"); let reject_existing = ObjectOptions { http_preconditions: Some(HTTPPreconditions { if_none_match: Some("conditional-etag".to_string()), ..Default::default() }), ..Default::default() }; assert!(matches!( set_disks.check_write_precondition(bucket, object, &reject_existing).await, Some(StorageError::PreconditionFailed) )); let allow_matching = ObjectOptions { http_preconditions: Some(HTTPPreconditions { if_match: Some("\"conditional-etag\"".to_string()), ..Default::default() }), ..Default::default() }; assert!( set_disks .check_write_precondition(bucket, object, &allow_matching) .await .is_none() ); let missing_if_match = ObjectOptions { http_preconditions: Some(HTTPPreconditions { if_match: Some("missing-etag".to_string()), ..Default::default() }), ..Default::default() }; assert!(matches!( set_disks .check_write_precondition(bucket, "missing-object.txt", &missing_if_match) .await, Some(StorageError::ObjectNotFound(_, _)) )); } #[tokio::test] async fn set_level_versioned_delete_marker_hides_object_without_corrupting_version_metadata() { let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await; let bucket = "bucket-versioned-delete"; let object = "object.txt"; let opts = ObjectOptions { no_lock: true, versioned: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(b"versioned object body".to_vec()); let written = set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("versioned object should be written"); assert!(written.version_id.is_some()); let marker = set_disks .delete_object(bucket, object, opts.clone()) .await .expect("versioned delete should create a marker"); assert!(marker.delete_marker); let marker_version = marker.version_id.expect("versioned delete marker should carry a version id"); let err = match set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("latest delete marker must hide object body"), Err(err) => err, }; assert!( is_err_object_not_found(&err), "latest delete marker read must map to ObjectNotFound, got {err:?}" ); let versioned_opts = ObjectOptions { version_id: Some(marker_version.to_string()), ..opts.clone() }; let err = match set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &versioned_opts) .await { Ok(_) => panic!("explicit delete marker version must not expose a body"), Err(err) => err, }; assert!( matches!(err, Error::MethodNotAllowed), "explicit delete marker version read must map to MethodNotAllowed, got {err:?}" ); } #[tokio::test] async fn set_level_metadata_self_copy_preserves_body_and_updates_metadata() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-metadata-copy"; let object = "object.txt"; let opts = ObjectOptions { no_lock: true, ..Default::default() }; let payload = b"metadata copy must not lose committed bytes".to_vec(); set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(payload.clone()); set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("object should be written"); let mut source = set_disks .get_object_info(bucket, object, &opts) .await .expect("object info should be readable"); let mut metadata = (*source.user_defined).clone(); metadata.insert("x-amz-meta-copy-check".to_string(), "present".to_string()); source.user_defined = Arc::new(metadata); source.metadata_only = true; source.etag = Some("metadata-copy-etag".to_string()); let copied = set_disks .copy_object(bucket, object, bucket, object, &mut source, &opts, &opts) .await .expect("metadata self-copy should succeed"); assert_eq!(copied.user_defined.get("x-amz-meta-copy-check").map(String::as_str), Some("present")); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("copied object reader should open"); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("copied object should stream"); assert_eq!(restored, payload); let reread = set_disks .get_object_info(bucket, object, &opts) .await .expect("copied object info should be readable"); assert_eq!(reread.user_defined.get("x-amz-meta-copy-check").map(String::as_str), Some("present")); } #[tokio::test] async fn set_level_empty_object_read_uses_buffered_empty_body_with_locks() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-empty-object"; let object = "empty.bin"; let opts = ObjectOptions::default(); set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(Vec::new()); let written = set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("empty object should be written"); assert_eq!(written.size, 0); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("empty object reader should open"); assert_eq!(get_reader.object_info.size, 0); assert_eq!(get_reader.buffered_body.as_ref().map(Bytes::len), Some(0)); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("empty object should stream"); assert!(restored.is_empty()); let info = set_disks .get_object_info(bucket, object, &opts) .await .expect("empty object info should be readable"); assert_eq!(info.size, 0); } #[tokio::test] async fn set_level_put_object_options_preserve_etag_and_normalize_standard_storage_class() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-put-options"; let object = "object.txt"; let mod_time = OffsetDateTime::from_unix_timestamp(1_717_171_717).expect("fixed timestamp should parse"); let mut user_defined = HashMap::new(); user_defined.insert(AMZ_STORAGE_CLASS.to_string(), storageclass::STANDARD.to_string()); user_defined.insert(SUFFIX_COMPRESSION.to_string(), "zstd".to_string()); let mut eval_metadata = HashMap::new(); eval_metadata.insert("x-amz-meta-evaluated".to_string(), "yes".to_string()); let opts = ObjectOptions { mod_time: Some(mod_time), preserve_etag: Some("preserved-etag".to_string()), user_defined, eval_metadata: Some(eval_metadata), ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(b"option matrix body".to_vec()); let written = set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("object should be written with option matrix"); assert_eq!(written.etag.as_deref(), Some("preserved-etag")); assert_eq!(written.mod_time, Some(mod_time)); assert_eq!(written.user_defined.get("x-amz-meta-evaluated").map(String::as_str), Some("yes")); assert!(!written.user_defined.contains_key(AMZ_STORAGE_CLASS)); let info = set_disks .get_object_info(bucket, object, &opts) .await .expect("object info should be readable"); assert_eq!(info.etag.as_deref(), Some("preserved-etag")); assert_eq!(info.mod_time, Some(mod_time)); assert_eq!(info.user_defined.get("x-amz-meta-evaluated").map(String::as_str), Some("yes")); assert!(!info.user_defined.contains_key(AMZ_STORAGE_CLASS)); } #[tokio::test] async fn set_level_put_object_metadata_updates_headers_without_rewriting_body() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-put-metadata"; let object = "object.txt"; let payload = b"metadata update must preserve bytes".to_vec(); let write_opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(payload.clone()); set_disks .put_object(bucket, object, &mut reader, &write_opts) .await .expect("object should be written"); let mut eval_metadata = HashMap::new(); eval_metadata.insert("x-amz-meta-updated".to_string(), "true".to_string()); let update_time = OffsetDateTime::from_unix_timestamp(1_717_181_818).expect("fixed timestamp should parse"); let update_opts = ObjectOptions { eval_metadata: Some(eval_metadata), mod_time: Some(update_time), ..Default::default() }; let updated = set_disks .put_object_metadata(bucket, object, &update_opts) .await .expect("metadata update should succeed"); assert_eq!(updated.mod_time, Some(update_time)); assert_eq!(updated.user_defined.get("x-amz-meta-updated").map(String::as_str), Some("true")); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &write_opts) .await .expect("updated object reader should open"); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("updated object should stream"); assert_eq!(restored, payload); } #[tokio::test] async fn set_level_copy_object_with_prefetched_reader_restores_body() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-copy-reader"; let object = "object.txt"; let payload = b"copy reader body".to_vec(); let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut source = ObjectInfo { metadata_only: false, put_object_reader: Some(PutObjReader::from_vec(payload.clone())), ..Default::default() }; let copied = set_disks .copy_object(bucket, object, bucket, object, &mut source, &opts, &opts) .await .expect("copy with prefetched reader should write object data"); assert_eq!(copied.size, payload.len() as i64); let mut get_reader = set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await .expect("copied reader should open"); let mut restored = Vec::new(); get_reader .stream .read_to_end(&mut restored) .await .expect("copied object should stream"); assert_eq!(restored, payload); } #[tokio::test] async fn set_level_version_suspended_delete_creates_null_delete_marker() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-version-suspended-delete"; let object = "object.txt"; let opts = ObjectOptions { no_lock: true, version_suspended: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(b"suspended version body".to_vec()); set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("suspended-version object should be written"); let marker = set_disks .delete_object(bucket, object, opts.clone()) .await .expect("version-suspended delete should create a null marker"); assert!(marker.delete_marker); assert_eq!(marker.version_id, Some(Uuid::nil())); let err = match set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("null delete marker must hide object body"), Err(err) => err, }; assert!( is_err_object_not_found(&err), "null delete marker read must map to ObjectNotFound, got {err:?}" ); } #[tokio::test] async fn set_level_delete_prefix_removes_nested_objects() { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-delete-prefix"; let object = "prefix/object.txt"; let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(b"prefix body".to_vec()); set_disks .put_object(bucket, object, &mut reader, &opts) .await .expect("prefix object should be written"); set_disks .delete_object( bucket, "prefix/", ObjectOptions { delete_prefix: true, no_lock: true, ..Default::default() }, ) .await .expect("prefix delete should succeed"); let err = match set_disks .get_object_reader(bucket, object, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("object under deleted prefix must not be readable"), Err(err) => err, }; assert!( is_err_object_not_found(&err), "prefix-deleted object must fail closed with object-not-found, got {err:?}" ); } #[tokio::test] #[serial] async fn set_level_delete_objects_batch_removes_live_data_and_tolerates_missing_keys() { temp_env::async_with_vars([("RUSTFS_ISSUE3031_DIAG_ENABLE", Some("true"))], async { let set_disks = make_local_bucket_test_set_disks().await; let bucket = "bucket-delete-objects"; let existing = "existing.txt"; let missing = "missing.txt"; let opts = ObjectOptions { no_lock: true, ..Default::default() }; set_disks .make_bucket(bucket, &MakeBucketOptions::default()) .await .expect("bucket should be created"); let mut reader = PutObjReader::from_vec(b"batch delete body".to_vec()); set_disks .put_object(bucket, existing, &mut reader, &opts) .await .expect("object should be written before batch delete"); let (deleted, errors) = set_disks .delete_objects( bucket, vec![ ObjectToDelete { object_name: existing.to_string(), ..Default::default() }, ObjectToDelete { object_name: missing.to_string(), ..Default::default() }, ], opts.clone(), ) .await; assert_eq!(deleted.len(), 2); assert_eq!(errors.len(), 2); assert!(errors.iter().all(Option::is_none)); assert_eq!(deleted[0].object_name, existing); assert!(deleted[0].found); assert!(!deleted[0].delete_marker); assert_eq!(deleted[1].object_name, missing); assert!(!deleted[1].found); assert!(!deleted[1].delete_marker); let err = match set_disks .get_object_reader(bucket, existing, None, HeaderMap::new(), &opts) .await { Ok(_) => panic!("batch-deleted object must not be readable as latest"), Err(err) => err, }; assert!( is_err_object_not_found(&err), "batch-deleted object must fail closed with object-not-found, got {err:?}" ); }) .await; } #[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> = 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:?}" ); } } }