Compare commits

..

10 Commits

Author SHA1 Message Date
马登山 88995b70b1 test(heal): cover suspended owner production path 2026-08-22 15:44:02 +08:00
马登山 64b89fb988 Merge remote-tracking branch 'origin/main' into codex/fix-pr-6348 2026-08-22 15:44:02 +08:00
Zhengchao An 7143697a5f fix(rpc): bound internode concurrency under multipart load (#6368) 2026-08-22 06:42:10 +00:00
cxymds a34310a58f fix(ecstore): fail closed on unresolved decommission entries (#6367)
* fix(ecstore): fail closed on unresolved decommission entries

* perf(ecstore): avoid successful listing name clone
2026-08-22 14:12:28 +08:00
cxymds 2e60029079 perf(ecstore): throttle decommission checkpoints (#6356) 2026-08-22 13:49:23 +08:00
Zhengchao An 2c3e68ad89 ci: let feature validation jobs finish (#6364) 2026-08-22 04:09:21 +00:00
cxymds 1871628568 Merge branch 'main' into cxymds/fix-1926-owner-selection 2026-08-22 11:26:24 +08:00
houseme 2f0918f60b feat(disk): fsync dedicated blocking pool (default-off) (#6366) 2026-08-22 11:24:24 +08:00
Zhengchao An 5b951de2b7 test(ci): bound s3-tests failure logs (#6361) 2026-08-22 02:58:01 +00:00
马登山 86d8509826 test(ecstore): cover suspended-owner heal semantics 2026-08-22 00:54:41 +08:00
8 changed files with 867 additions and 133 deletions
+3 -3
View File
@@ -400,7 +400,7 @@ jobs:
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 45
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
@@ -440,7 +440,7 @@ jobs:
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 60
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
@@ -470,7 +470,7 @@ jobs:
if: github.event_name != 'pull_request' || github.event.action != 'closed'
needs: [ quick-checks ]
runs-on: sm-standard-4
timeout-minutes: 60
timeout-minutes: 90
strategy:
# On a PR, one failing protocol leg is enough to know the PR is not ready,
# so stop the sibling leg instead of paying another ~40 minutes for it.
+7
View File
@@ -57,6 +57,13 @@ pub const DEFAULT_MAX_IO_EVENTS_PER_TICK: usize = 1024;
pub const DEFAULT_EVENT_INTERVAL: u32 = 61;
pub const DEFAULT_RNG_SEED: Option<u64> = None; // None means random
/// Dedicated blocking thread pool for fsync/fdatasync operations.
/// When > 1, fsync operations are isolated from the main blocking pool to
/// prevent device-bound fsync from starving read operations (pread/stat/open).
/// Default 0 means auto (no isolation, use main runtime).
pub const ENV_FSYNC_BLOCKING_THREADS: &str = "RUSTFS_RUNTIME_FSYNC_BLOCKING_THREADS";
pub const DEFAULT_FSYNC_BLOCKING_THREADS: usize = 0;
// Dial9 Tokio Telemetry Default values
pub const DEFAULT_RUNTIME_DIAL9_ENABLED: bool = false; // Disabled by default
pub const DEFAULT_RUNTIME_DIAL9_OUTPUT_DIR: &str = "/var/log/rustfs/telemetry";
+507 -122
View File
@@ -36,7 +36,8 @@ use crate::disk::error::DiskError;
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
use crate::error::{Error, Result};
use crate::error::{
StorageError, is_err_bucket_exists, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
StorageError, is_err_bucket_exists, is_err_bucket_not_found, is_err_object_not_found, is_err_operation_canceled,
is_err_version_not_found,
};
use crate::layout::endpoints::EndpointServerPools;
use crate::object_api::{GetObjectReader, ObjectOptions};
@@ -89,6 +90,7 @@ const DECOMMISSION_STAGE_SOURCE_CLEANUP: &str = "source_cleanup";
const DECOMMISSION_STAGE_ENTRY_FINISHED: &str = "entry_finished";
const DECOMMISSION_PROGRESS_SAVE_INTERVAL: Duration = Duration::seconds(30);
const DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD: usize = 1000;
const DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF: Duration = Duration::seconds(1);
const DECOMMISSION_BUCKET_CONCURRENCY_ENV: &str = "RUSTFS_DECOMMISSION_BUCKET_CONCURRENCY";
const DECOMMISSION_BUCKET_CONCURRENCY_DEFAULT_CAP: usize = 4;
const DECOMMISSION_TARGET_CAPACITY_OVERHEAD_PERCENT: usize = 30;
@@ -638,22 +640,6 @@ fn track_decommission_current_object(meta: &mut PoolMeta, idx: usize, bucket: &s
track_decommission_current_object_stage(meta, idx, bucket, object, "")
}
fn touch_decommission_progress(meta: &mut PoolMeta, idx: usize) -> Result<()> {
let pool_count = meta.pools.len();
ensure_valid_decommission_pool_index(pool_count, idx)?;
let Some(pool) = meta.pools.get_mut(idx) else {
return Err(invalid_decommission_pool_index_error(pool_count, idx));
};
let Some(info) = pool.decommission.as_mut() else {
return Err(decommission_metadata_not_initialized_error("touch decommission progress"));
};
pool.last_update = OffsetDateTime::now_utc();
info.mark_progress_saved();
Ok(())
}
fn resolve_decommission_update_after_result(result: Result<bool>) -> Result<bool> {
result.map_err(|err| Error::other(format!("decommission metadata update failed: {err}")))
}
@@ -773,7 +759,76 @@ async fn load_decommission_entry_exact_versions(
}
fn resolve_decommission_check_after_list_result(list_result: Result<()>, entry_error: Option<Error>) -> Result<()> {
if let Some(err) = entry_error { Err(err) } else { list_result }
match list_result {
Ok(()) => entry_error.map_or(Ok(()), Err),
Err(list_err) => resolve_decommission_listing_error(Some(list_err), entry_error).map_or(Ok(()), Err),
}
}
fn resolve_decommission_listing_error(listing_error: Option<Error>, entry_error: Option<Error>) -> Option<Error> {
match (listing_error, entry_error) {
(Some(listing_error), Some(entry_error)) if is_err_operation_canceled(&listing_error) => Some(entry_error),
(Some(listing_error), Some(entry_error)) if is_err_operation_canceled(&entry_error) => Some(listing_error),
(Some(listing_error), _) => Some(listing_error),
(None, entry_error) => entry_error,
}
}
fn decommission_unresolved_listing_error(
bucket: &str,
prefix: &str,
candidate: Option<&str>,
candidate_count: usize,
disk_error_count: usize,
pool_index: usize,
set_index: usize,
) -> Error {
let location = candidate.unwrap_or(prefix);
Error::other(format!(
"decommission listing could not resolve metadata for {bucket}/{location} on pool {pool_index} set {set_index} ({candidate_count} candidate(s), {disk_error_count} disk error(s))"
))
}
fn resolve_decommission_partial_listing_entry(
entries: MetaCacheEntries,
resolver: MetadataResolutionParams,
bucket: &str,
prefix: &str,
disk_error_count: usize,
pool_index: usize,
set_index: usize,
) -> Result<MetaCacheEntry> {
let candidate_count = entries.as_ref().iter().flatten().count();
if let Some(entry) = entries.resolve(resolver) {
return Ok(entry);
}
let candidate = entries.as_ref().iter().flatten().map(|entry| entry.name.as_str()).next();
Err(decommission_unresolved_listing_error(
bucket,
prefix,
candidate,
candidate_count,
disk_error_count,
pool_index,
set_index,
))
}
async fn record_decommission_entry_error(
entry_error: &Arc<tokio::sync::Mutex<Option<Error>>>,
rx: &CancellationToken,
err: Error,
) {
if rx.is_cancelled() {
return;
}
let mut first_err = entry_error.lock().await;
if first_err.is_none() && !rx.is_cancelled() {
*first_err = Some(err);
rx.cancel();
}
}
fn resolve_decommission_pool_meta_reload_result(result: Result<()>, stage: &str) -> Result<()> {
@@ -1483,6 +1538,7 @@ impl TryFrom<PersistedPoolDecommissionInfo> for PoolDecommissionInfo {
terminal_reload_attempt_at: value.terminal_reload_attempt_at,
terminal_reload_failures: value.terminal_reload_failures,
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
progress_save_retry_after: None,
})
}
}
@@ -1514,6 +1570,7 @@ impl TryFrom<LegacyPoolDecommissionInfo> for PoolDecommissionInfo {
terminal_reload_attempt_at: None,
terminal_reload_failures: Vec::new(),
progress_save_item_baseline: value.items_decommissioned.saturating_add(value.items_decommission_failed),
progress_save_retry_after: None,
})
}
}
@@ -1627,6 +1684,82 @@ impl PoolMeta {
}
}
fn decommission_progress_checkpoint(
&self,
idx: usize,
duration: Duration,
now: OffsetDateTime,
) -> Result<Option<DecommissionProgressCheckpoint>> {
let pool_count = self.pools.len();
ensure_valid_decommission_pool_index(pool_count, idx)?;
let Some(pool) = self.pools.get(idx) else {
return Err(invalid_decommission_pool_index_error(pool_count, idx));
};
let Some(info) = pool.decommission.as_ref() else {
return Err(decommission_metadata_not_initialized_error("update decommission metadata timestamp"));
};
if info.progress_save_retry_after.is_some_and(|retry_after| now < retry_after) {
return Ok(None);
}
let time_threshold_reached = now.unix_timestamp() - pool.last_update.unix_timestamp() >= duration.whole_seconds();
let item_threshold_reached = info.items_since_last_progress_save() >= DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD;
if !time_threshold_reached && !item_threshold_reached {
return Ok(None);
}
Ok(Some(DecommissionProgressCheckpoint {
start_time: info.start_time,
queued: info.queued,
counted_items: info.counted_items(),
checkpoint_at: now,
}))
}
fn commit_decommission_progress_checkpoint(&mut self, idx: usize, checkpoint: DecommissionProgressCheckpoint) -> bool {
let Some(pool) = self.pools.get_mut(idx) else {
return false;
};
let Some(info) = pool.decommission.as_mut() else {
return false;
};
if info.start_time != checkpoint.start_time
|| info.queued != checkpoint.queued
|| !is_decommission_active(info.complete, info.failed, info.canceled)
{
return false;
}
info.progress_save_item_baseline = info.progress_save_item_baseline.max(checkpoint.counted_items);
info.progress_save_retry_after = None;
pool.last_update = pool.last_update.max(checkpoint.checkpoint_at);
true
}
fn defer_decommission_progress_checkpoint(
&mut self,
idx: usize,
checkpoint: DecommissionProgressCheckpoint,
retry_after: OffsetDateTime,
) {
let Some(pool) = self.pools.get_mut(idx) else {
return;
};
let Some(info) = pool.decommission.as_mut() else {
return;
};
if info.start_time == checkpoint.start_time
&& info.queued == checkpoint.queued
&& is_decommission_active(info.complete, info.failed, info.canceled)
{
info.progress_save_retry_after = Some(retry_after);
}
}
fn load_from_config_data(&mut self, data: Vec<u8>) -> Result<()> {
if data.is_empty() {
return Ok(());
@@ -1987,30 +2120,9 @@ impl PoolMeta {
}
pub fn update_after(&mut self, idx: usize, duration: Duration) -> Result<bool> {
let pool_count = self.pools.len();
ensure_valid_decommission_pool_index(pool_count, idx)?;
let (last_update, item_threshold_reached) = match self.pools.get(idx) {
Some(pool) if let Some(info) = pool.decommission.as_ref() => (
pool.last_update,
info.items_since_last_progress_save() >= DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
),
Some(_) => {
return Err(decommission_metadata_not_initialized_error("update decommission metadata timestamp"));
}
None => return Err(invalid_decommission_pool_index_error(pool_count, idx)),
};
let now = OffsetDateTime::now_utc();
if now.unix_timestamp() - last_update.unix_timestamp() >= duration.whole_seconds() || item_threshold_reached {
let Some(pool) = self.pools.get_mut(idx) else {
return Err(invalid_decommission_pool_index_error(pool_count, idx));
};
pool.last_update = now;
return Ok(true);
}
Ok(false)
Ok(self
.decommission_progress_checkpoint(idx, duration, OffsetDateTime::now_utc())?
.is_some())
}
pub fn validate(&self, pools: Vec<Arc<Sets>>) -> Result<bool> {
@@ -2151,6 +2263,16 @@ pub struct PoolDecommissionInfo {
pub terminal_reload_failures: Vec<String>,
#[serde(skip)]
pub progress_save_item_baseline: usize,
#[serde(skip)]
pub progress_save_retry_after: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DecommissionProgressCheckpoint {
start_time: Option<OffsetDateTime>,
queued: bool,
counted_items: usize,
checkpoint_at: OffsetDateTime,
}
impl PoolDecommissionInfo {
@@ -2185,6 +2307,7 @@ impl PoolDecommissionInfo {
fn mark_progress_saved(&mut self) {
self.progress_save_item_baseline = self.counted_items();
self.progress_save_retry_after = None;
}
pub fn bucket_push(&mut self, bucket: &DecomBucketInfo) {
@@ -2489,6 +2612,40 @@ impl ECStore {
snapshot.save(self.pools.clone()).await
}
async fn save_decommission_progress_checkpoint(&self, idx: usize) -> Result<bool> {
// Lock order: save gate, then the short pool metadata read/write sections. Peer
// reloads are intentionally performed by the caller after both locks are released.
let _save_guard = self.pool_meta_save_gate.lock().await;
let (snapshot, checkpoint) = {
let pool_meta = self.pool_meta.read().await;
let Some(checkpoint) = pool_meta.decommission_progress_checkpoint(
idx,
DECOMMISSION_PROGRESS_SAVE_INTERVAL,
OffsetDateTime::now_utc(),
)?
else {
return Ok(false);
};
let mut snapshot = pool_meta.clone();
let Some(pool) = snapshot.pools.get_mut(idx) else {
return Err(invalid_decommission_pool_index_error(snapshot.pools.len(), idx));
};
pool.last_update = checkpoint.checkpoint_at;
(snapshot, checkpoint)
};
if let Err(err) = snapshot.save(self.pools.clone()).await {
let retry_after = OffsetDateTime::now_utc() + DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
let mut pool_meta = self.pool_meta.write().await;
pool_meta.defer_decommission_progress_checkpoint(idx, checkpoint, retry_after);
return Err(err);
}
let mut pool_meta = self.pool_meta.write().await;
Ok(pool_meta.commit_decommission_progress_checkpoint(idx, checkpoint))
}
async fn save_current_pool_meta_for_decommission_start(
&self,
indices: &[usize],
@@ -2871,7 +3028,7 @@ impl ECStore {
Ok(())
}
async fn save_decommission_entry_progress_stage(
async fn track_decommission_entry_progress_stage(
&self,
idx: usize,
bucket: &str,
@@ -2882,22 +3039,6 @@ impl ECStore {
let mut pool_meta = self.pool_meta.write().await;
track_decommission_current_object_stage(&mut pool_meta, idx, bucket, object, stage)
.map_err(|err| with_decommission_entry_context(stage, bucket, object, err))?;
touch_decommission_progress(&mut pool_meta, idx)
.map_err(|err| with_decommission_entry_context(stage, bucket, object, err))?;
}
if let Some(err) = resolve_decommission_progress_save_result(self.save_current_pool_meta().await) {
warn!(
event = EVENT_DECOMMISSION_ENTRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
bucket = %bucket,
object = %object,
stage,
error = ?err,
"Decommission progress stage save failed"
);
}
Ok(())
@@ -3165,7 +3306,7 @@ impl ECStore {
let bucket_name = bucket.clone();
let object_name = rd.object_info.name.clone();
self.save_decommission_entry_progress_stage(
self.track_decommission_entry_progress_stage(
idx,
bucket_name.as_str(),
object_name.as_str(),
@@ -3259,7 +3400,7 @@ impl ECStore {
}
decommission_cancel_signal_result(rx.is_cancelled())?;
self.save_decommission_entry_progress_stage(
self.track_decommission_entry_progress_stage(
idx,
bucket.as_str(),
entry.name.as_str(),
@@ -3267,7 +3408,7 @@ impl ECStore {
)
.await?;
self.save_decommission_entry_progress_stage(
self.track_decommission_entry_progress_stage(
idx,
bucket.as_str(),
entry.name.as_str(),
@@ -3334,34 +3475,42 @@ impl ECStore {
}
};
self.save_decommission_entry_progress_stage(idx, bucket.as_str(), entry.name.as_str(), DECOMMISSION_STAGE_ENTRY_FINISHED)
.await?;
self.track_decommission_entry_progress_stage(
idx,
bucket.as_str(),
entry.name.as_str(),
DECOMMISSION_STAGE_ENTRY_FINISHED,
)
.await?;
if should_save_progress {
let save_result = self.save_current_pool_meta().await;
if let Some(err) = resolve_decommission_progress_save_result(save_result) {
warn!(
event = EVENT_DECOMMISSION_ENTRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
bucket = %bucket,
object = %entry.name,
state = "progress_save_failed",
error = %err,
"Decommission progress save failed; continuing and will retry at the next checkpoint"
);
} else {
let mut pool_meta = self.pool_meta.write().await;
pool_meta.mark_decommission_progress_saved();
if let Some(notification_sys) = runtime_sources::notification_sys()
&& let Err(err) = resolve_decommission_entry_reload_result(
notification_sys.reload_pool_meta().await,
bucket.as_str(),
entry.name.as_str(),
)
{
warn!("{err}");
match self.save_decommission_progress_checkpoint(idx).await {
Ok(true) => {
if let Some(notification_sys) = runtime_sources::notification_sys()
&& let Err(err) = resolve_decommission_entry_reload_result(
notification_sys.reload_pool_meta().await,
bucket.as_str(),
entry.name.as_str(),
)
{
warn!("{err}");
}
}
Ok(false) => {}
Err(err) => {
if let Some(err) = resolve_decommission_progress_save_result(Err(err)) {
warn!(
event = EVENT_DECOMMISSION_ENTRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
bucket = %bucket,
object = %entry.name,
state = "progress_save_failed",
error = %err,
"Decommission progress save failed; continuing and will retry at the next checkpoint"
);
}
}
}
}
@@ -3538,6 +3687,7 @@ impl ECStore {
let rx_clone = rx.clone();
let bi = bi.clone();
let set_id = set_idx;
let listing_entry_error = entry_error.clone();
let worker = tokio::spawn(async move {
let _listing_permit = listing_permit;
run_decommission_listing_with_retry(
@@ -3551,7 +3701,11 @@ impl ECStore {
let set = set.clone();
let rx = rx_clone.clone();
let bucket = bi.clone();
async move { set.list_objects_to_decommission(rx, bucket, callback).await }
let entry_error = listing_entry_error.clone();
async move {
set.list_objects_to_decommission(rx, bucket, callback, entry_error.clone(), idx, set_id)
.await
}
},
)
.await
@@ -3581,11 +3735,7 @@ impl ECStore {
wait_decommission_worker_drain(&workers, worker_limit).await?;
if let Some(err) = listing_worker_error {
return Err(err);
}
if let Some(err) = entry_error.lock().await.clone() {
if let Some(err) = resolve_decommission_listing_error(listing_worker_error, entry_error.lock().await.clone()) {
return Err(err);
}
@@ -4191,7 +4341,7 @@ impl ECStore {
let buckets = self.get_buckets_to_decommission().await?;
let pool = self.pools[idx].clone();
for set in &pool.disk_set {
for (set_index, set) in pool.disk_set.iter().enumerate() {
for bucket_info in &buckets {
let mut lifecycle_config = None;
let mut object_lock_config = None;
@@ -4286,7 +4436,7 @@ impl ECStore {
});
let list_result = set
.list_objects_to_decommission(callback_rx, bucket_info.clone(), callback)
.list_objects_to_decommission(callback_rx, bucket_info.clone(), callback, entry_error.clone(), idx, set_index)
.await;
let entry_error = entry_error.lock().await.clone();
resolve_decommission_check_after_list_result(list_result, entry_error)?;
@@ -5021,12 +5171,15 @@ mod tests {
pub type ListCallback = Arc<dyn Fn(MetaCacheEntry) -> BoxFuture<'static, ()> + Send + Sync + 'static>;
impl SetDisks {
#[tracing::instrument(skip(self, rx, cb_func))]
#[tracing::instrument(skip(self, rx, cb_func, entry_error))]
async fn list_objects_to_decommission(
self: &Arc<Self>,
rx: CancellationToken,
bucket_info: DecomBucketInfo,
cb_func: ListCallback,
entry_error: Arc<tokio::sync::Mutex<Option<Error>>>,
pool_index: usize,
set_index: usize,
) -> Result<()> {
let (disks, _) = self.get_online_disks_with_healing(false).await;
ensure_decommission_listing_disks_available(!disks.is_empty(), &bucket_info.name)?;
@@ -5041,6 +5194,12 @@ impl SetDisks {
};
let cb1 = cb_func.clone();
let unresolved_error = entry_error.clone();
let unresolved_rx = rx.clone();
let unresolved_bucket = bucket_info.name.clone();
let unresolved_prefix = bucket_info.prefix.clone();
let unresolved_pool_index = pool_index;
let unresolved_set_index = set_index;
list_path_raw(
rx,
@@ -5053,20 +5212,51 @@ impl SetDisks {
skip_walkdir_total_timeout: true,
walkdir_stall_timeout: Some(DECOMMISSION_BACKGROUND_WALKDIR_STALL_TIMEOUT),
agreed: Some(Box::new(move |entry: MetaCacheEntry| Box::pin(cb1(entry)))),
partial: Some(Box::new(move |entries: MetaCacheEntries, _: &[Option<DiskError>]| {
partial: Some(Box::new(move |entries: MetaCacheEntries, errs: &[Option<DiskError>]| {
let resolver = resolver.clone();
let cb_func = cb_func.clone();
match entries.resolve(resolver) {
Some(entry) => {
let bucket = unresolved_bucket.clone();
let prefix = unresolved_prefix.clone();
let unresolved_error = unresolved_error.clone();
let unresolved_rx = unresolved_rx.clone();
let pool_index = unresolved_pool_index;
let set_index = unresolved_set_index;
let disk_error_count = errs.iter().flatten().count();
if unresolved_rx.is_cancelled() {
return Box::pin(async {});
}
match resolve_decommission_partial_listing_entry(
entries,
resolver,
&bucket,
&prefix,
disk_error_count,
pool_index,
set_index,
) {
Ok(entry) => {
warn!("decommission_pool: list_objects_to_decommission get {}", &entry.name);
Box::pin(async move {
cb_func(entry).await;
})
}
None => {
warn!("decommission_pool: list_objects_to_decommission get none");
Box::pin(async {})
}
Err(err) => Box::pin(async move {
if unresolved_rx.is_cancelled() {
return;
}
warn!(
event = EVENT_DECOMMISSION_BUCKET,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
bucket = %bucket,
prefix = %prefix,
state = "unresolved_entry",
error = %err,
"Decommission listing failed closed on unresolved metadata"
);
record_decommission_entry_error(&unresolved_error, &unresolved_rx, err).await;
}),
}
})),
..Default::default()
@@ -5074,6 +5264,10 @@ impl SetDisks {
)
.await?;
if let Some(err) = entry_error.lock().await.clone() {
return Err(err);
}
Ok(())
}
}
@@ -5264,11 +5458,11 @@ pub(crate) fn fallback_free_capacity_dedup(disks: &[rustfs_madmin::Disk]) -> usi
#[cfg(test)]
mod pools_tests {
use super::{
DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD, DecomBucketInfo,
DecommissionStartPoolState, DecommissionTerminalState, ListCallback, PoolDecommissionInfo, PoolMeta, PoolSpaceInfo,
PoolStatus, apply_decommission_status_space_info, bind_decommission_cancelers, bind_missing_decommission_cancelers,
cancel_decommission_canceler, classify_decommission_terminal_state, count_decommission_item,
decommission_cancel_signal_result, decommission_item_size, decommission_meta_bucket_options,
DECOMMISSION_PROGRESS_SAVE_INTERVAL, DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD, DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF,
DecomBucketInfo, DecommissionStartPoolState, DecommissionTerminalState, ListCallback, PoolDecommissionInfo, PoolMeta,
PoolSpaceInfo, PoolStatus, apply_decommission_status_space_info, bind_decommission_cancelers,
bind_missing_decommission_cancelers, cancel_decommission_canceler, classify_decommission_terminal_state,
count_decommission_item, decommission_cancel_signal_result, decommission_item_size, decommission_meta_bucket_options,
decommission_start_pool_state, dedup_indices, default_decommission_bucket_concurrency,
ensure_decommission_cancel_allowed, ensure_decommission_clear_allowed, ensure_decommission_listing_disks_available,
ensure_decommission_not_rebalancing, ensure_decommission_start_allowed, ensure_decommission_start_keeps_active_pool,
@@ -5279,11 +5473,12 @@ mod pools_tests {
has_active_decommission_canceler, is_decommission_active, is_decommission_cancel_requested,
load_decommission_entry_versions, local_decommission_queue_prefix, mark_decommission_bucket_done,
merge_pool_status_refresh, missing_decommission_worker_prefix, observe_decommission_terminal_reload_result,
pool_meta_has_active_decommission, require_decommission_store, resolve_decommission_bucket_done_save_result,
resolve_decommission_bucket_state, resolve_decommission_check_after_list_result,
resolve_decommission_entry_cleanup_delete_result, resolve_decommission_entry_exact_versions,
resolve_decommission_entry_reload_result, resolve_decommission_listing_worker_result,
resolve_decommission_optional_bucket_config_result, resolve_decommission_pool_meta_reload_result,
pool_meta_has_active_decommission, record_decommission_entry_error, require_decommission_store,
resolve_decommission_bucket_done_save_result, resolve_decommission_bucket_state,
resolve_decommission_check_after_list_result, resolve_decommission_entry_cleanup_delete_result,
resolve_decommission_entry_exact_versions, resolve_decommission_entry_reload_result, resolve_decommission_listing_error,
resolve_decommission_listing_worker_result, resolve_decommission_optional_bucket_config_result,
resolve_decommission_partial_listing_entry, resolve_decommission_pool_meta_reload_result,
resolve_decommission_preflight_heal_result, resolve_decommission_progress_save_result,
resolve_decommission_spawn_failure_result, resolve_decommission_terminal_mark_after_error_result,
resolve_decommission_terminal_mark_result, resolve_decommission_update_after_result,
@@ -5293,16 +5488,17 @@ mod pools_tests {
should_preserve_decommission_canceled_state, should_reject_decommission_cancel_as_terminal,
should_retry_decommission_cancel_reload, should_retry_decommission_listing, should_skip_canceled_decommission_routine,
split_decommission_buckets, take_and_cancel_decommission_canceler, take_decommission_canceler,
touch_decommission_progress, track_decommission_current_object, track_decommission_current_object_stage,
validate_start_decommission_request, wait_decommission_listing_retry, wait_decommission_worker_drain,
with_decommission_entry_context,
track_decommission_current_object, track_decommission_current_object_stage, validate_start_decommission_request,
wait_decommission_listing_retry, wait_decommission_worker_drain, with_decommission_entry_context,
};
use crate::data_movement;
use crate::disk::endpoint::Endpoint;
use crate::error::{Error, StorageError};
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::services::rebalance::{RebalStatus, RebalanceInfo, RebalanceMeta, RebalanceStats};
use rustfs_filemeta::{FileInfo, FileInfoVersions, MetaCacheEntry, ObjectPartInfo};
use rustfs_filemeta::{
FileInfo, FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo,
};
use rustfs_rio::Index;
use std::sync::{
Arc,
@@ -6321,6 +6517,65 @@ mod pools_tests {
assert!(matches!(err, Error::SlowDown));
}
#[test]
fn test_resolve_decommission_partial_listing_entry_rejects_unresolved_metadata() {
let err = resolve_decommission_partial_listing_entry(
MetaCacheEntries(vec![None]),
MetadataResolutionParams {
dir_quorum: 2,
obj_quorum: 2,
bucket: "bucket-a".to_string(),
..Default::default()
},
"bucket-a",
"prefix/",
1,
2,
3,
)
.expect_err("unresolved partial listing must fail closed");
let message = err.to_string();
assert!(message.contains("decommission listing could not resolve metadata"));
assert!(message.contains("bucket-a/prefix/"));
assert!(message.contains("pool 2 set 3"));
assert!(message.contains("1 disk error(s)"));
}
#[tokio::test]
async fn test_record_decommission_entry_error_cancels_listing_and_preserves_first_error() {
let entry_error = Arc::new(tokio::sync::Mutex::new(None));
let rx = CancellationToken::new();
record_decommission_entry_error(&entry_error, &rx, Error::SlowDown).await;
record_decommission_entry_error(&entry_error, &rx, Error::OperationCanceled).await;
assert!(rx.is_cancelled());
assert!(matches!(*entry_error.lock().await, Some(Error::SlowDown)));
}
#[tokio::test]
async fn test_record_decommission_entry_error_ignores_already_canceled_listing() {
let entry_error = Arc::new(tokio::sync::Mutex::new(None));
let rx = CancellationToken::new();
rx.cancel();
record_decommission_entry_error(&entry_error, &rx, Error::SlowDown).await;
assert!(entry_error.lock().await.is_none());
}
#[test]
fn test_resolve_decommission_listing_error_preserves_real_listing_failure() {
let err = resolve_decommission_listing_error(Some(Error::SlowDown), Some(Error::OperationCanceled))
.expect("listing failure should be returned");
assert!(matches!(err, Error::SlowDown));
let err = resolve_decommission_listing_error(Some(Error::OperationCanceled), Some(Error::SlowDown))
.expect("entry failure should be returned");
assert!(matches!(err, Error::SlowDown));
}
#[test]
fn test_resolve_decommission_check_after_list_result_returns_list_result_without_entry_error() {
let err = resolve_decommission_check_after_list_result(Err(Error::OperationCanceled), None)
@@ -6538,7 +6793,7 @@ mod pools_tests {
}
#[test]
fn test_touch_decommission_progress_updates_last_update_and_save_baseline() {
fn test_track_decommission_stage_does_not_advance_checkpoint_state() {
let mut meta = PoolMeta {
pools: vec![PoolStatus {
id: 0,
@@ -6553,11 +6808,13 @@ mod pools_tests {
..Default::default()
};
touch_decommission_progress(&mut meta, 0).expect("valid decommission progress should be touched");
track_decommission_current_object_stage(&mut meta, 0, "bucket", "object", "migrate_object")
.expect("valid decommission progress should be tracked");
assert!(meta.pools[0].last_update > OffsetDateTime::UNIX_EPOCH);
assert_eq!(meta.pools[0].last_update, OffsetDateTime::UNIX_EPOCH);
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
assert_eq!(info.items_since_last_progress_save(), 0);
assert_eq!(info.items_since_last_progress_save(), 5);
assert_eq!(info.stage, "migrate_object");
}
#[test]
@@ -6632,6 +6889,134 @@ mod pools_tests {
assert_eq!(info.items_since_last_progress_save(), 1);
}
#[test]
fn test_pool_meta_update_after_does_not_advance_last_update_before_save() {
let last_update = OffsetDateTime::UNIX_EPOCH;
let mut meta = PoolMeta {
pools: vec![PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update,
decommission: Some(PoolDecommissionInfo {
start_time: Some(last_update),
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
..Default::default()
}),
}],
..Default::default()
};
assert!(
meta.update_after(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL)
.expect("item threshold should request a checkpoint")
);
assert_eq!(meta.pools[0].last_update, last_update);
}
#[test]
fn test_decommission_progress_checkpoint_commits_exact_snapshot_watermark() {
let start_time = OffsetDateTime::UNIX_EPOCH;
let checkpoint_at = start_time + Duration::seconds(30);
let mut meta = PoolMeta {
pools: vec![PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update: start_time,
decommission: Some(PoolDecommissionInfo {
start_time: Some(start_time),
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
..Default::default()
}),
}],
..Default::default()
};
let checkpoint = meta
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
.expect("valid decommission state should produce a checkpoint")
.expect("item threshold should produce a checkpoint");
meta.count_item(0, 1, false);
assert!(meta.commit_decommission_progress_checkpoint(0, checkpoint));
let info = meta.pools[0].decommission.as_ref().expect("decommission info should exist");
assert_eq!(info.progress_save_item_baseline, checkpoint.counted_items);
assert_eq!(info.items_since_last_progress_save(), 1);
assert_eq!(meta.pools[0].last_update, checkpoint_at);
}
#[test]
fn test_decommission_progress_checkpoint_backoff_does_not_advance_baseline() {
let start_time = OffsetDateTime::UNIX_EPOCH;
let checkpoint_at = start_time + Duration::seconds(30);
let retry_after = checkpoint_at + DECOMMISSION_PROGRESS_SAVE_RETRY_BACKOFF;
let mut meta = PoolMeta {
pools: vec![PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update: start_time,
decommission: Some(PoolDecommissionInfo {
start_time: Some(start_time),
items_decommissioned: DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD,
..Default::default()
}),
}],
..Default::default()
};
let checkpoint = meta
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
.expect("valid decommission state should produce a checkpoint")
.expect("item threshold should produce a checkpoint");
meta.defer_decommission_progress_checkpoint(0, checkpoint, retry_after);
assert!(
meta.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
.expect("retry backoff check should succeed")
.is_none()
);
assert_eq!(meta.pools[0].last_update, start_time);
assert_eq!(
meta.pools[0]
.decommission
.as_ref()
.expect("decommission info should exist")
.progress_save_item_baseline,
0
);
}
#[test]
fn test_decommission_progress_checkpoint_count_scales_with_threshold() {
let start_time = OffsetDateTime::UNIX_EPOCH;
let checkpoint_at = start_time;
let mut meta = PoolMeta {
pools: vec![PoolStatus {
id: 0,
cmd_line: "pool-0".to_string(),
last_update: start_time,
decommission: Some(PoolDecommissionInfo {
start_time: Some(start_time),
..Default::default()
}),
}],
..Default::default()
};
let mut checkpoint_count = 0;
for _ in 0..(DECOMMISSION_PROGRESS_SAVE_ITEM_THRESHOLD * 10) {
meta.count_item(0, 1, false);
if let Some(checkpoint) = meta
.decommission_progress_checkpoint(0, DECOMMISSION_PROGRESS_SAVE_INTERVAL, checkpoint_at)
.expect("valid decommission state should produce a checkpoint")
{
checkpoint_count += 1;
assert!(meta.commit_decommission_progress_checkpoint(0, checkpoint));
}
}
assert_eq!(checkpoint_count, 10);
}
#[test]
fn test_ensure_decommission_not_rebalancing_rejects_running_rebalance() {
let err = ensure_decommission_not_rebalancing(true).expect_err("rebalance running should be rejected");
+42 -4
View File
@@ -315,7 +315,7 @@ pub async fn fsync_dir(dir: impl AsRef<Path>) -> io::Result<()> {
#[cfg(unix)]
{
let dir = dir.as_ref().to_path_buf();
tokio::task::spawn_blocking(move || fsync_dir_std(dir)).await?
fsync_spawn_blocking(move || fsync_dir_std(dir)).await?
}
#[cfg(not(unix))]
@@ -683,7 +683,7 @@ async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup) -> io::Result<()> {
#[cfg(test)]
let dir = group.dir.clone();
let dir_file = group.dir_file.clone();
tokio::task::spawn_blocking(move || {
fsync_spawn_blocking(move || {
#[cfg(test)]
{
if let Some(kind) = fsync_dir_recorder::take_grouped_failure(&dir) {
@@ -1080,6 +1080,44 @@ const TEST_GLOBAL_FILE_SYNCS: usize = 64;
static FILE_SYNC_PERMITS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(global_file_sync_limit()));
static DISK_FILE_SYNC_LIMITERS: LazyLock<Mutex<HashMap<PathBuf, Weak<Semaphore>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
/// Dedicated tokio runtime for fsync/fdatasync blocking operations. When
/// configured with >1 threads, isolates device-bound fsync from the main
/// blocking pool so reads (pread/stat/open) are not starved. `None` means
/// fall back to the main runtime (zero behavior change).
static FSYNC_RUNTIME: LazyLock<Option<tokio::runtime::Runtime>> = LazyLock::new(|| {
let threads =
rustfs_utils::get_env_usize(rustfs_config::ENV_FSYNC_BLOCKING_THREADS, rustfs_config::DEFAULT_FSYNC_BLOCKING_THREADS);
if threads <= 1 {
return None;
}
let mut builder = tokio::runtime::Builder::new_multi_thread();
builder
.worker_threads(num_cpus::get().min(8))
.max_blocking_threads(threads)
.thread_name("rustfs-fsync")
.thread_stack_size(512 * 1024)
.enable_all();
match builder.build() {
Ok(rt) => {
tracing::info!(threads, "fsync dedicated blocking pool enabled");
Some(rt)
}
Err(err) => {
tracing::warn!(%err, "failed to build fsync runtime, falling back to main pool");
None
}
}
});
/// Spawn a blocking task on the fsync-dedicated runtime if configured,
/// otherwise fall back to the main tokio blocking pool.
fn fsync_spawn_blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> tokio::task::JoinHandle<T> {
match FSYNC_RUNTIME.as_ref() {
Some(rt) => rt.spawn_blocking(f),
None => tokio::task::spawn_blocking(f),
}
}
static DISK_VOLUME_MUTATION_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Weak<RwLock<()>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
type NamespaceMutationLock = AsyncMutex<()>;
@@ -1217,7 +1255,7 @@ where
F: FnOnce() -> io::Result<T> + Send + 'static,
{
let (disk_permit, global_permit) = acquire_file_sync_permits(disk_permits).await?;
let result = tokio::task::spawn_blocking(move || {
let result = fsync_spawn_blocking(move || {
let _disk_permit = disk_permit;
work()
})
@@ -2146,7 +2184,7 @@ async fn run_blocking_namespace_file_sync_operation_with_global<T: Send + 'stati
wait_started,
);
let disk_permit = admission.disk_permit.clone();
let result = tokio::task::spawn_blocking(move || {
let result = fsync_spawn_blocking(move || {
let _lease = lease;
let _disk_permit = disk_permit;
operation()
+276 -2
View File
@@ -297,10 +297,16 @@ impl ECStore {
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::metadata_sys;
use crate::core::pools::{PoolDecommissionInfo, PoolStatus};
use crate::disk::{DiskOption, format::FormatV3, new_disk};
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
use crate::disk::{DeleteOptions, DiskOption, format::FormatV3, new_disk};
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::runtime::instance::InstanceContext;
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations};
use crate::store::init_format::{load_format_erasure, save_format_file};
use crate::store::init_local_disks_with_instance_ctx;
use tokio_util::sync::CancellationToken;
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
let format = FormatV3::new(1, 1);
@@ -347,6 +353,51 @@ mod tests {
}
}
async fn multi_pool_heal_store() -> (tempfile::TempDir, Arc<ECStore>, CancellationToken) {
let temp_dir = tempfile::tempdir().expect("multi-pool heal test directory should be created");
let mut pool_endpoints = Vec::new();
for pool_index in 0..2 {
let mut endpoints = Vec::new();
for disk_index in 0..4 {
let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}"));
tokio::fs::create_dir_all(&disk_path)
.await
.expect("multi-pool heal test disk should be created");
let mut endpoint = Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8"))
.expect("test endpoint should parse");
endpoint.set_pool_index(pool_index);
endpoint.set_set_index(0);
endpoint.set_disk_index(disk_index);
endpoints.push(endpoint);
}
pool_endpoints.push(PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: format!("heal-owner-pool-{pool_index}"),
platform: "test".to_string(),
});
}
let endpoint_pools = EndpointServerPools::from(pool_endpoints);
let instance_ctx = Arc::new(InstanceContext::new());
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
.await
.expect("multi-pool local disks should initialize");
let shutdown = CancellationToken::new();
let store = ECStore::new_with_instance_ctx(
"127.0.0.1:0".parse().expect("test address should parse"),
endpoint_pools,
shutdown.clone(),
instance_ctx,
)
.await
.expect("multi-pool test store should initialize");
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(temp_dir, store, shutdown)
}
#[tokio::test]
async fn heal_object_pool_scope_selects_only_requested_pool() {
let store = minimal_heal_store().await;
@@ -506,6 +557,229 @@ mod tests {
}
}
#[tokio::test]
#[serial_test::serial]
async fn unscoped_heal_object_suspended_owner_semantics() {
let (_temp_dir, store, shutdown) = multi_pool_heal_store().await;
let bucket = format!("heal-owner-{}", Uuid::new_v4().simple());
let active_object = "active-owner";
let suspended_only_object = "suspended-only";
let duplicate_object = "duplicate-owner";
let marker_object = "marker-owner";
let quorum_object = "quorum-owner";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created in all pools");
let mut active_reader = PutObjReader::from_vec(b"active owner".to_vec());
store.pools[0]
.put_object(&bucket, active_object, &mut active_reader, &ObjectOptions::default())
.await
.expect("active owner object should be written");
let active_disks = store.pools[0].disk_set[0].disks.read().await.clone();
let missing_active_disk = active_disks[0].clone().expect("active disk should be online");
missing_active_disk
.delete(
&bucket,
active_object,
DeleteOptions {
recursive: true,
immediate: true,
..Default::default()
},
)
.await
.expect("active owner shard should be removed for repair");
assert!(
missing_active_disk.read_xl(&bucket, active_object, false).await.is_err(),
"the active owner fixture must start with one missing metadata copy"
);
let mut suspended_reader = PutObjReader::from_vec(b"suspended owner".to_vec());
store.pools[1]
.put_object(&bucket, suspended_only_object, &mut suspended_reader, &ObjectOptions::default())
.await
.expect("suspended owner object should be written");
for (pool_index, mod_time) in [1_i64, 2_i64].into_iter().enumerate() {
let mut duplicate_reader = PutObjReader::from_vec(format!("duplicate-pool-{pool_index}").into_bytes());
store.pools[pool_index]
.put_object(
&bucket,
duplicate_object,
&mut duplicate_reader,
&ObjectOptions {
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(mod_time)),
..Default::default()
},
)
.await
.expect("duplicate owner object should be written");
}
let duplicate_missing_disk = store.pools[0].disk_set[0].disks.read().await[0]
.clone()
.expect("duplicate active owner disk should be online");
duplicate_missing_disk
.delete(
&bucket,
duplicate_object,
DeleteOptions {
recursive: true,
immediate: true,
..Default::default()
},
)
.await
.expect("duplicate active owner shard should be removed for repair");
let history_version = Uuid::new_v4();
let mut history_reader = PutObjReader::from_vec(b"marker history".to_vec());
store.pools[0]
.put_object(
&bucket,
marker_object,
&mut history_reader,
&ObjectOptions {
versioned: true,
version_id: Some(history_version.to_string()),
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1)),
..Default::default()
},
)
.await
.expect("versioned marker history should be written");
store.pools[0]
.delete_object(
&bucket,
marker_object,
ObjectOptions {
versioned: true,
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(2)),
..Default::default()
},
)
.await
.expect("delete marker should be written");
let mut quorum_reader = PutObjReader::from_vec(b"quorum boundary".to_vec());
store.pools[0]
.put_object(&bucket, quorum_object, &mut quorum_reader, &ObjectOptions::default())
.await
.expect("quorum boundary object should be written");
{
let mut pool_meta = store.pool_meta.write().await;
let mut next = PoolMeta::new(&store.pools, &pool_meta);
next.pools[1].decommission = Some(PoolDecommissionInfo {
start_time: Some(OffsetDateTime::UNIX_EPOCH),
..Default::default()
});
*pool_meta = next;
}
let (_, duplicate_owner) = store
.get_latest_object_info_with_idx(&bucket, duplicate_object, &ObjectOptions::default())
.await
.expect("duplicate owner should resolve");
assert_eq!(duplicate_owner, 1, "latest duplicate must win when all pools are eligible");
let (_, active_duplicate_owner) = store
.get_latest_object_info_with_idx(
&bucket,
duplicate_object,
&ObjectOptions {
skip_decommissioned: true,
..Default::default()
},
)
.await
.expect("active duplicate owner should resolve");
assert_eq!(
active_duplicate_owner, 0,
"suspended duplicate must be excluded from active owner selection"
);
let (duplicate_result, duplicate_err) = store
.handle_heal_object(&bucket, duplicate_object, "", &HealOpts::default())
.await
.expect("duplicate owner heal should complete through the production path");
assert_eq!(duplicate_result.object, duplicate_object);
assert!(duplicate_err.is_none(), "active duplicate should be repaired: {duplicate_err:?}");
assert!(
duplicate_missing_disk.read_xl(&bucket, duplicate_object, false).await.is_ok(),
"production heal must repair the active duplicate owner rather than the suspended owner"
);
let (marker_info, marker_owner) = store
.get_latest_object_info_with_idx(
&bucket,
marker_object,
&ObjectOptions {
skip_decommissioned: true,
versioned: true,
..Default::default()
},
)
.await
.expect("latest delete marker should resolve");
assert_eq!(marker_owner, 0);
assert!(marker_info.delete_marker, "latest version must preserve delete-marker semantics");
let (active_result, active_err) = store
.handle_heal_object(&bucket, active_object, "", &HealOpts::default())
.await
.expect("unscoped active-owner heal should complete");
assert_eq!(active_result.object, active_object);
assert!(active_err.is_none(), "active owner must be selected even with a suspended pool");
assert!(
missing_active_disk.read_xl(&bucket, active_object, false).await.is_ok(),
"active owner heal must write the missing disk metadata: result={active_result:?}, err={active_err:?}"
);
assert!(
store.pools[1]
.get_object_info(&bucket, active_object, &ObjectOptions::default())
.await
.is_err(),
"the suspended pool must not be written for an active-owner object"
);
let (suspended_result, suspended_err) = store
.handle_heal_object(&bucket, suspended_only_object, "", &HealOpts::default())
.await
.expect("unscoped suspended-only heal should return a terminal result");
assert!(suspended_result.object.is_empty());
assert!(matches!(suspended_err, Some(Error::FileNotFound)));
assert!(
store.pools[1]
.get_object_info(&bucket, suspended_only_object, &ObjectOptions::default())
.await
.is_ok(),
"suspended-only data must remain untouched when unscoped heal reports absent"
);
let (_, explicit_err) = store
.handle_heal_object(
&bucket,
suspended_only_object,
"",
&HealOpts {
pool: Some(1),
..Default::default()
},
)
.await
.expect("explicit suspended-owner heal should return a mapped error");
assert!(matches!(explicit_err, Some(Error::SlowDown)));
let original_quorum_disks = store.pools[0].disk_set[0].disks.read().await.clone();
let surviving_quorum_disk = original_quorum_disks[3].clone();
*store.pools[0].disk_set[0].disks.write().await = vec![None, None, None, surviving_quorum_disk];
let (_, quorum_err) = store
.handle_heal_object(&bucket, quorum_object, "", &HealOpts::default())
.await
.expect("quorum boundary heal should return a mapped result");
*store.pools[0].disk_set[0].disks.write().await = original_quorum_disks;
assert!(
matches!(quorum_err, Some(Error::ErasureReadQuorum)),
"quorum-boundary heal must preserve quorum error, got {quorum_err:?}"
);
shutdown.cancel();
}
#[tokio::test]
async fn handle_heal_format_continues_after_a_pool_error() {
let canonical_format = FormatV3::new(1, 3);
+4
View File
@@ -2106,6 +2106,9 @@ pub enum ChannelClass {
Bulk,
}
// Keep multiplexed unary RPCs below h2's per-connection small-frame budget.
const INTERNODE_RPC_CONCURRENCY_LIMIT: usize = 64;
/// Whether control/bulk channel isolation is enabled (env-gated, default off for safe rollout).
fn channel_isolation_enabled() -> bool {
rustfs_utils::get_env_bool(
@@ -2188,6 +2191,7 @@ async fn build_channel(dial_addr: &str, cache_key: &str) -> Result<Channel, Box<
let mut connector = Endpoint::from_shared(dial_addr.to_string())?
// Fast connection timeout for dead peer detection
.connect_timeout(connect_timeout)
.concurrency_limit(INTERNODE_RPC_CONCURRENCY_LIMIT)
// TCP-level keepalive - OS will probe connection
.tcp_keepalive(Some(tcp_keepalive))
// Disable Nagle so latency-sensitive control-plane RPCs (locks/health) are not batched
+26 -1
View File
@@ -206,6 +206,13 @@ def check_runner_selection(root: Path) -> list[str]:
return errors
def check_s3_tests_runner(root: Path) -> list[str]:
runner = (root / "scripts/s3-tests/run.sh").read_text()
if "--showlocals" in runner:
return ["scripts/s3-tests/run.sh: pytest failure diagnostics must not dump local values"]
return []
def profile_selection(root: Path, profile: str) -> str:
if not re.fullmatch(r"e2e-[a-z0-9-]+", profile):
raise ValueError(f"invalid e2e profile name: {profile}")
@@ -272,6 +279,7 @@ def validate(root: Path) -> list[str]:
errors.extend(check_e2e_modules(root))
errors.extend(check_fuzz_targets(root))
errors.extend(check_runner_selection(root))
errors.extend(check_s3_tests_runner(root))
errors.extend(check_profile_definitions(root))
return errors
@@ -341,6 +349,23 @@ class SelfTests(unittest.TestCase):
)
self.assertEqual(len(check_fuzz_targets(root)), 1)
def test_s3_runner_rejects_unbounded_failure_locals(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runner = root / "scripts/s3-tests/run.sh"
runner.parent.mkdir(parents=True)
runner.write_text("tox -- -vv -ra --tb=long\n")
self.assertEqual(check_s3_tests_runner(root), [])
runner.write_text("tox -- -vv -ra --showlocals --tb=long\n")
self.assertEqual(len(check_s3_tests_runner(root)), 1)
with (
mock.patch(__name__ + ".check_e2e_modules", return_value=[]),
mock.patch(__name__ + ".check_fuzz_targets", return_value=[]),
mock.patch(__name__ + ".check_runner_selection", return_value=[]),
mock.patch(__name__ + ".check_profile_definitions", return_value=[]),
):
self.assertEqual(len(validate(root)), 1)
def test_profile_listing_enforces_selection(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -411,7 +436,7 @@ def main() -> int:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print("OK: e2e modules, runner selection, fuzz matrices, and profile guards are wired")
print("OK: e2e modules, runner selection, fuzz matrices, profiles, and bounded diagnostics are wired")
return 0
+2 -1
View File
@@ -1028,10 +1028,11 @@ else
fi
# Run tests from s3tests/functional
# Failure locals can contain multi-MiB request bodies; keep tracebacks without expanding local values.
set +e
S3TEST_CONF="${CONF_OUTPUT_PATH}" \
tox -- \
-vv -ra --showlocals --tb=long \
-vv -ra --tb=long \
--maxfail="${MAXFAIL}" \
--timeout="${TEST_TIMEOUT}" \
--junitxml="${ARTIFACTS_DIR}/junit.xml" \