mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 20:36:38 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 657835f12c | |||
| ca8d4c2ea7 | |||
| ca999e42f5 | |||
| 65a0eabfbf | |||
| 84f6b10186 | |||
| 7202c9937e | |||
| 5677201fc6 | |||
| 77604a5908 | |||
| f1bf990588 | |||
| 4a07a0a917 | |||
| 906d982ced | |||
| 971b84c4b5 | |||
| 631a30d6e2 | |||
| cd06111dcb | |||
| d139c8a8c3 | |||
| 3793552885 | |||
| fdd193ff36 | |||
| 8679570c2a | |||
| 7143697a5f | |||
| a34310a58f | |||
| 2e60029079 | |||
| 2c3e68ad89 | |||
| 2f0918f60b | |||
| 5b951de2b7 |
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
+535
-125
@@ -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(),
|
||||
@@ -3275,6 +3416,9 @@ impl ECStore {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let source_cleanup_mutation_fence = self
|
||||
.acquire_decommission_source_cleanup_fence(bucket.as_str(), entry.name.as_str(), set.as_ref())
|
||||
.await?;
|
||||
let cleanup_result = data_movement::cleanup_source_entry_if_unchanged(
|
||||
set.clone(),
|
||||
bucket.as_str(),
|
||||
@@ -3286,6 +3430,7 @@ impl ECStore {
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
object_mutation_fence: Some(&source_cleanup_mutation_fence),
|
||||
},
|
||||
"decommission",
|
||||
)
|
||||
@@ -3334,34 +3479,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3379,6 +3532,22 @@ impl ECStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn decommission_entry_for_test(
|
||||
self: &Arc<Self>,
|
||||
idx: usize,
|
||||
entry: MetaCacheEntry,
|
||||
bucket: String,
|
||||
set: Arc<SetDisks>,
|
||||
) -> Result<()> {
|
||||
let worker_permit = Arc::new(Semaphore::new(1))
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("decommission test worker permit acquire failed: {err}")))?;
|
||||
self.decommission_entry(CancellationToken::new(), idx, entry, bucket, set, worker_permit, None, None, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, rx))]
|
||||
async fn decommission_pool(
|
||||
self: &Arc<Self>,
|
||||
@@ -3538,6 +3707,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 +3721,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 +3755,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 +4361,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 +4456,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)?;
|
||||
@@ -4314,15 +4484,20 @@ impl ECStore {
|
||||
) -> Result<()> {
|
||||
warn!("decommission_object: start {} {}", &bucket, &rd.object_info.name);
|
||||
let object_name = rd.object_info.name.clone();
|
||||
let result = data_movement::migrate_object(
|
||||
let mut migration = tokio::task::JoinSet::new();
|
||||
migration.spawn(data_movement::migrate_decommission_object(
|
||||
self,
|
||||
pool_idx,
|
||||
bucket.clone(),
|
||||
rd,
|
||||
expected_bucket_incarnation_id,
|
||||
"decommission_object",
|
||||
)
|
||||
.await;
|
||||
));
|
||||
let result = migration
|
||||
.join_next()
|
||||
.await
|
||||
.ok_or_else(|| Error::other("decommission migration task was not started"))?
|
||||
.map_err(|err| Error::other(format!("decommission migration task join error: {err}")))?;
|
||||
if result.is_ok() {
|
||||
warn!("decommission_object: migrated {} {}", &bucket, &object_name);
|
||||
}
|
||||
@@ -5021,12 +5196,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 +5219,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 +5237,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 +5289,10 @@ impl SetDisks {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(err) = entry_error.lock().await.clone() {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5264,11 +5483,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 +5498,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 +5513,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 +6542,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 +6818,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 +6833,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 +6914,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");
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::storage_api_contracts::{
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{HTTPPreconditions, ObjectOperations as _},
|
||||
};
|
||||
use crate::store::ECStore;
|
||||
use crate::store::{ECStore, ObjectLockDiagGuard, SourceCleanupMutationFence};
|
||||
use bytes::Bytes;
|
||||
use rustfs_filemeta::{FileInfo, FileInfoVersions, ObjectPartInfo};
|
||||
use rustfs_rio::{EtagResolvable, HashReader, HashReaderDetector, Index, TryGetIndex};
|
||||
@@ -856,7 +856,6 @@ fn is_equivalent_data_movement_object(source: &ObjectInfo, target: &ObjectInfo)
|
||||
fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
is_unversioned_data_movement_object(source)
|
||||
&& is_unversioned_data_movement_object(target)
|
||||
&& !target.delete_marker
|
||||
&& source
|
||||
.mod_time
|
||||
.zip(target.mod_time)
|
||||
@@ -1028,6 +1027,7 @@ pub(crate) enum SourceCleanupError {
|
||||
pub(crate) struct SourceCleanupBucketFence<'a> {
|
||||
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
|
||||
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
|
||||
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
|
||||
}
|
||||
|
||||
fn ensure_source_cleanup_versions_match(
|
||||
@@ -1065,7 +1065,9 @@ pub(crate) async fn ensure_source_cleanup_versions_unchanged(
|
||||
struct SourceCleanupDeleteBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
fence_pending: tokio::sync::Notify,
|
||||
arrived: tokio::sync::Notify,
|
||||
is_paused: AtomicBool,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
@@ -1079,7 +1081,7 @@ pub(crate) struct SourceCleanupDeleteBarrier {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static SOURCE_CLEANUP_DELETE_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<SourceCleanupDeleteBarrierState>>>> =
|
||||
static SOURCE_CLEANUP_DELETE_BARRIERS: std::sync::OnceLock<std::sync::Mutex<Vec<Arc<SourceCleanupDeleteBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1092,15 +1094,22 @@ impl SourceCleanupDeleteBarrier {
|
||||
let state = Arc::new(SourceCleanupDeleteBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
fence_pending: tokio::sync::Notify::new(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
is_paused: AtomicBool::new(false),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = SOURCE_CLEANUP_DELETE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
let mut barriers = SOURCE_CLEANUP_DELETE_BARRIERS
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "source cleanup delete barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
assert!(
|
||||
!barriers
|
||||
.iter()
|
||||
.any(|barrier| barrier.bucket == bucket && barrier.object == object),
|
||||
"source cleanup delete barrier must be unique per object"
|
||||
);
|
||||
barriers.push(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
@@ -1110,35 +1119,58 @@ impl SourceCleanupDeleteBarrier {
|
||||
.expect("source cleanup should reach the pre-delete barrier");
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_fence_pending(&self) {
|
||||
tokio::time::timeout(StdDuration::from_secs(30), self.state.fence_pending.notified())
|
||||
.await
|
||||
.expect("source cleanup should attempt the fixed mutation fence");
|
||||
}
|
||||
|
||||
pub(crate) fn is_paused(&self) -> bool {
|
||||
self.state.is_paused.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn notify_source_cleanup_mutation_fence_pending(bucket: &str, object: &str) {
|
||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIERS
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison")
|
||||
.iter()
|
||||
.find(|barrier| barrier.bucket == bucket && barrier.object == object)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.fence_pending.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for SourceCleanupDeleteBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = SOURCE_CLEANUP_DELETE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
let mut barriers = SOURCE_CLEANUP_DELETE_BARRIERS
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
barriers.retain(|state| !Arc::ptr_eq(state, &self.state));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
|
||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIERS
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket && barrier.object == object)
|
||||
.iter()
|
||||
.find(|barrier| barrier.bucket == bucket && barrier.object == object)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.is_paused.store(true, Ordering::Release);
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
@@ -1154,11 +1186,20 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
op_label: &str,
|
||||
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
|
||||
let cleanup_key = encode_dir_object(object);
|
||||
let ns_lock = set.new_ns_lock(bucket, cleanup_key.as_str()).await?;
|
||||
let _guard = ns_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
let source_guard = if bucket_fence
|
||||
.object_mutation_fence
|
||||
.is_some_and(SourceCleanupMutationFence::source_lock_covered)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
let ns_lock = set.new_ns_lock(bucket, cleanup_key.as_str()).await?;
|
||||
Some(
|
||||
ns_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(Error::from)?,
|
||||
)
|
||||
};
|
||||
|
||||
if bucket_fence
|
||||
.lifecycle_guard
|
||||
@@ -1168,6 +1209,14 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
"{op_label}: bucket incarnation fence was lost before source cleanup"
|
||||
))));
|
||||
}
|
||||
if bucket_fence
|
||||
.object_mutation_fence
|
||||
.is_some_and(SourceCleanupMutationFence::is_lock_lost)
|
||||
{
|
||||
return Err(SourceCleanupError::Storage(Error::other(format!(
|
||||
"{op_label}: object mutation fence was lost before source cleanup"
|
||||
))));
|
||||
}
|
||||
|
||||
ensure_source_cleanup_versions_unchanged(set.clone(), bucket, object, expected, allowed_missing, op_label).await?;
|
||||
|
||||
@@ -1182,7 +1231,12 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
expected_bucket_incarnation_id: bucket_fence.expected_incarnation_id,
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_namespace_lock_guard(&_guard);
|
||||
if let Some(source_guard) = source_guard.as_ref() {
|
||||
opts.add_namespace_lock_guard(source_guard);
|
||||
}
|
||||
if let Some(object_mutation_fence) = bucket_fence.object_mutation_fence {
|
||||
object_mutation_fence.add_namespace_lock_fence(&mut opts);
|
||||
}
|
||||
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
|
||||
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
|
||||
}
|
||||
@@ -1330,6 +1384,37 @@ fn data_movement_part_upload_failure_stage(err: &Error) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn migrate_decommission_object(
|
||||
store: Arc<ECStore>,
|
||||
pool_idx: usize,
|
||||
bucket: String,
|
||||
rd: GetObjectReader,
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
) -> Result<()> {
|
||||
let source = rd.object_info.clone();
|
||||
let _mutation_fence = store
|
||||
.acquire_decommission_object_mutation_fence(&bucket, &source.name)
|
||||
.await?;
|
||||
let current = find_data_movement_target_info(store.as_ref(), pool_idx, &bucket, &source)
|
||||
.await?
|
||||
.ok_or(Error::FileNotFound)?;
|
||||
if !is_equivalent_data_movement_object_identity(&source, ¤t, true, false) {
|
||||
return Err(Error::FileNotFound);
|
||||
}
|
||||
|
||||
migrate_object_inner(
|
||||
store,
|
||||
pool_idx,
|
||||
bucket,
|
||||
rd,
|
||||
source_bucket_incarnation_id,
|
||||
op_label,
|
||||
Some(&_mutation_fence),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn migrate_object(
|
||||
store: Arc<ECStore>,
|
||||
pool_idx: usize,
|
||||
@@ -1337,6 +1422,18 @@ pub(crate) async fn migrate_object(
|
||||
rd: GetObjectReader,
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
) -> Result<()> {
|
||||
migrate_object_inner(store, pool_idx, bucket, rd, source_bucket_incarnation_id, op_label, None).await
|
||||
}
|
||||
|
||||
async fn migrate_object_inner(
|
||||
store: Arc<ECStore>,
|
||||
pool_idx: usize,
|
||||
bucket: String,
|
||||
rd: GetObjectReader,
|
||||
source_bucket_incarnation_id: Option<uuid::Uuid>,
|
||||
op_label: &str,
|
||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
||||
) -> Result<()> {
|
||||
let object_info = rd.object_info.clone();
|
||||
let has_part_checksums = object_info
|
||||
@@ -1350,7 +1447,7 @@ pub(crate) async fn migrate_object(
|
||||
let mut new_multipart_opts = data_movement_new_multipart_opts(&object_info, pool_idx);
|
||||
new_multipart_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
||||
let (res, target_pool_idx, expected_bucket_incarnation_id) = match store
|
||||
.handle_new_multipart_upload_with_pool_idx(&bucket, &object_info.name, &new_multipart_opts)
|
||||
.handle_new_multipart_upload_with_pool_idx(&bucket, &object_info.name, &new_multipart_opts, mutation_fence)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
@@ -1448,7 +1545,7 @@ pub(crate) async fn migrate_object(
|
||||
if let Err(err) = store
|
||||
.clone()
|
||||
.complete_multipart_upload_for_data_movement(
|
||||
target_pool_idx,
|
||||
(target_pool_idx, mutation_fence),
|
||||
&bucket,
|
||||
&object_info.name,
|
||||
&res.upload_id,
|
||||
@@ -1609,7 +1706,7 @@ pub(crate) async fn migrate_object(
|
||||
let mut put_opts = data_movement_put_object_opts(&object_info, pool_idx);
|
||||
put_opts.expected_bucket_incarnation_id = source_bucket_incarnation_id;
|
||||
let (target_pool_idx, put_result) = store
|
||||
.put_object_for_data_movement(&bucket, &object_info.name, &mut data, &put_opts)
|
||||
.put_object_for_data_movement(&bucket, &object_info.name, &mut data, &put_opts, mutation_fence)
|
||||
.await
|
||||
.map_err(|err| data_movement_stage_error(op_label, "prepare_put_object", &bucket, &object_info.name, err))?;
|
||||
if let Err(err) = put_result {
|
||||
@@ -3541,25 +3638,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_rejects_newer_delete_marker() {
|
||||
let source = ObjectInfo {
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
delete_marker: true,
|
||||
etag: None,
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
fn test_precondition_conflict_accepts_only_newer_null_delete_marker() {
|
||||
for version_id in [None, Some(Uuid::nil())] {
|
||||
let source = ObjectInfo {
|
||||
version_id,
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
delete_marker: true,
|
||||
etag: None,
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("delete marker conflict should be evaluated");
|
||||
assert!(
|
||||
resolve_data_movement_overwrite_resume_result(
|
||||
&Error::PreconditionFailed,
|
||||
Ok(Some(target.clone())),
|
||||
&source,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.expect("newer null delete marker should be evaluated")
|
||||
);
|
||||
|
||||
assert!(!should_resume);
|
||||
let mut same_time = target.clone();
|
||||
same_time.mod_time = source.mod_time;
|
||||
assert!(
|
||||
!resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(same_time)), &source, 0, 1,)
|
||||
.expect("same-generation null delete marker should be rejected")
|
||||
);
|
||||
|
||||
let mut versioned = target;
|
||||
versioned.version_id = Some(Uuid::new_v4());
|
||||
assert!(
|
||||
!resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(versioned)), &source, 0, 1,)
|
||||
.expect("a UUID delete marker must not erase a null source version")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -858,6 +858,7 @@ const EVENT_DISK_LOCAL_DIRECT_IO_FALLBACK: &str = "disk_local_direct_io_fallback
|
||||
#[cfg(target_os = "linux")]
|
||||
const EVENT_DISK_LOCAL_URING_LATCH_OFF: &str = "disk_local_uring_latch_off";
|
||||
const EVENT_DISK_LOCAL_DELETE_FAILED: &str = "disk_local_delete_failed";
|
||||
const EVENT_DISK_LOCAL_DELETE_ROLLBACK_FAILED: &str = "disk_local_delete_rollback_failed";
|
||||
const EVENT_DISK_LOCAL_CHECK_PARTS: &str = "disk_local_check_parts";
|
||||
const EVENT_DISK_LOCAL_ACCESS_FAILED: &str = "disk_local_access_failed";
|
||||
const EVENT_DISK_LOCAL_VOLUME_SETUP_FAILED: &str = "disk_local_volume_setup_failed";
|
||||
@@ -6106,6 +6107,43 @@ impl LocalDisk {
|
||||
Ok((bytes, modtime))
|
||||
}
|
||||
|
||||
async fn write_missing_delete_marker(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
fi: FileInfo,
|
||||
object_dir: &Path,
|
||||
xl_path: &Path,
|
||||
rollback_dir: Option<Uuid>,
|
||||
) -> Result<()> {
|
||||
if let Some(rollback_dir) = rollback_dir {
|
||||
let rollback_path = object_dir.join(rollback_dir.to_string());
|
||||
fs::create_dir_all(&rollback_path).await.map_err(to_file_error)?;
|
||||
fs::write(rollback_path.join(DELETE_MARKER_ROLLBACK_FILE), [])
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
if let Err(err) = self.write_metadata("", volume, path, fi).await {
|
||||
if let Some(rollback_dir) = rollback_dir
|
||||
&& let Err(restore_err) = restore_delete_rollback(object_dir, xl_path, rollback_dir, &self.publication_root).await
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_DELETE_ROLLBACK_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
result = "failed",
|
||||
volume,
|
||||
path,
|
||||
rollback_dir = %rollback_dir,
|
||||
error = ?restore_err,
|
||||
"Disk local delete rollback failed"
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_versions_internal(&self, volume: &str, path: &str, fis: &[FileInfo], opts: &DeleteOptions) -> Result<()> {
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
let xlpath = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
||||
@@ -6123,7 +6161,20 @@ impl LocalDisk {
|
||||
return restore_metadata_backup(object_dir, &xlpath, rollback_dir, &self.publication_root).await;
|
||||
}
|
||||
|
||||
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await?;
|
||||
let (data, _) = match self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await {
|
||||
Ok(data) => data,
|
||||
Err(DiskError::FileNotFound) => {
|
||||
// `deleted` alone can be an explicit marker purge; only
|
||||
// `mark_deleted` may create metadata that was not present.
|
||||
let Some(delete_marker) = fis.iter().find(|fi| fi.deleted && fi.mark_deleted).cloned() else {
|
||||
return Err(DiskError::FileNotFound);
|
||||
};
|
||||
return self
|
||||
.write_missing_delete_marker(volume, path, delete_marker, object_dir, &xlpath, opts.old_data_dir)
|
||||
.await;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if data.is_empty() {
|
||||
return Err(DiskError::FileNotFound);
|
||||
@@ -10422,29 +10473,9 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
if fi.deleted && force_del_marker {
|
||||
if let Some(rollback_dir) = rollback_dir {
|
||||
let rollback_path = file_path.join(rollback_dir.to_string());
|
||||
fs::create_dir_all(&rollback_path).await.map_err(to_file_error)?;
|
||||
fs::write(rollback_path.join(DELETE_MARKER_ROLLBACK_FILE), [])
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
if let Err(err) = self.write_metadata("", volume, path, fi).await {
|
||||
if let Some(rollback_dir) = rollback_dir
|
||||
&& let Err(restore_err) =
|
||||
restore_delete_rollback(file_path.as_path(), &xl_path, rollback_dir, &self.publication_root).await
|
||||
{
|
||||
warn!(
|
||||
volume,
|
||||
path,
|
||||
rollback_dir = %rollback_dir,
|
||||
error = ?restore_err,
|
||||
"failed to restore metadata after delete marker commit error"
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
return Ok(());
|
||||
return self
|
||||
.write_missing_delete_marker(volume, path, fi, file_path.as_path(), &xl_path, rollback_dir)
|
||||
.await;
|
||||
}
|
||||
|
||||
return if fi.version_id.is_some() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::storage_api_contracts::{
|
||||
pub struct NamespaceLockFence {
|
||||
signals: Arc<Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
||||
#[cfg(test)]
|
||||
forced_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
forced_lost: Arc<Vec<Arc<std::sync::atomic::AtomicBool>>>,
|
||||
}
|
||||
|
||||
impl Debug for NamespaceLockFence {
|
||||
@@ -40,13 +40,17 @@ impl NamespaceLockFence {
|
||||
Self {
|
||||
signals: Arc::default(),
|
||||
#[cfg(test)]
|
||||
forced_lost: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
forced_lost: Arc::new(vec![Arc::new(std::sync::atomic::AtomicBool::new(false))]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
#[cfg(test)]
|
||||
if self.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if self
|
||||
.forced_lost
|
||||
.iter()
|
||||
.any(|lost| lost.load(std::sync::atomic::Ordering::Acquire))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.signals.iter().any(|signal| signal.is_lost())
|
||||
@@ -57,27 +61,26 @@ impl NamespaceLockFence {
|
||||
}
|
||||
|
||||
fn extend(&mut self, other: &Self) {
|
||||
if Arc::ptr_eq(&self.signals, &other.signals) {
|
||||
return;
|
||||
if !Arc::ptr_eq(&self.signals, &other.signals) {
|
||||
Arc::make_mut(&mut self.signals).extend(other.signals.iter().cloned());
|
||||
}
|
||||
Arc::make_mut(&mut self.signals).extend(other.signals.iter().cloned());
|
||||
#[cfg(test)]
|
||||
if other.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||
self.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
if !Arc::ptr_eq(&self.forced_lost, &other.forced_lost) {
|
||||
Arc::make_mut(&mut self.forced_lost).extend(other.forced_lost.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lost_for_test() -> Self {
|
||||
let fence = Self::new();
|
||||
fence.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
fence.forced_lost[0].store(true, std::sync::atomic::Ordering::Release);
|
||||
fence
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn loss_handle_for_test() -> (Self, Arc<std::sync::atomic::AtomicBool>) {
|
||||
let fence = Self::new();
|
||||
(fence.clone(), Arc::clone(&fence.forced_lost))
|
||||
(fence.clone(), Arc::clone(&fence.forced_lost[0]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +414,13 @@ impl ObjectOptions {
|
||||
self.namespace_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn add_namespace_lock_fence_for_test(&mut self, fence: &NamespaceLockFence) {
|
||||
self.namespace_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.extend(fence);
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_lifecycle_delete_all_journal(&mut self) {
|
||||
self.lifecycle_delete_all_journal
|
||||
.get_or_insert_with(|| Arc::new(parking_lot::Mutex::new(LifecycleDeleteAllJournalState::default())));
|
||||
|
||||
@@ -334,6 +334,7 @@ impl ECStore {
|
||||
lifecycle_guard: bucket_incarnation_fence
|
||||
.as_ref()
|
||||
.and_then(|guard| guard.namespace_lock_guard()),
|
||||
..Default::default()
|
||||
},
|
||||
"rebalance",
|
||||
),
|
||||
|
||||
@@ -735,8 +735,12 @@ pub(crate) use core::io_primitives::disk_call_counters;
|
||||
mod ctx;
|
||||
mod metadata;
|
||||
mod ops;
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::multipart::NewMultipartUploadCommitObservation;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::object::DeleteObjectCommitBarrier;
|
||||
#[cfg(feature = "test-util")]
|
||||
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
|
||||
pub(crate) use ops::object::body_cache_plaintext_len;
|
||||
@@ -3025,6 +3029,16 @@ pub struct SetDisks {
|
||||
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
|
||||
}
|
||||
|
||||
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
||||
// each endpoint's canonical Arc, so an exact Arc set identifies the lock domain.
|
||||
pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
|
||||
left.iter()
|
||||
.all(|left_client| right.iter().any(|right_client| Arc::ptr_eq(left_client, right_client)))
|
||||
&& right
|
||||
.iter()
|
||||
.all(|right_client| left.iter().any(|left_client| Arc::ptr_eq(left_client, right_client)))
|
||||
}
|
||||
|
||||
const ERASURE_CACHE_MAX_ENTRIES: usize = 32;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -3600,6 +3614,15 @@ impl SetDisks {
|
||||
&self.ctx
|
||||
}
|
||||
|
||||
/// Whether both sets' namespace-lock implementations cover the same object key.
|
||||
pub(crate) async fn shares_namespace_lock_domain(&self, other: &Self) -> bool {
|
||||
match (self.ctx.is_dist_erasure().await, other.ctx.is_dist_erasure().await) {
|
||||
(false, false) => Arc::ptr_eq(&self.local_lock_manager, &other.local_lock_manager),
|
||||
(true, true) => same_distributed_lock_domain(&self.lockers, &other.lockers),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<rustfs_lock::GlobalLockManager> {
|
||||
@@ -4584,11 +4607,11 @@ fn should_preserve_delete_replication_state(opts: &ObjectOptions) -> bool {
|
||||
}
|
||||
|
||||
fn should_force_delete_marker_for_missing_version(opts: &ObjectOptions) -> bool {
|
||||
opts.delete_marker || (opts.versioned && opts.version_id.is_none() && !opts.data_movement)
|
||||
opts.delete_marker || ((opts.versioned || opts.version_suspended) && 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 mark_delete = goi.version_id.is_some() || ((opts.versioned || opts.version_suspended) && opts.version_id.is_none());
|
||||
let mut delete_marker = opts.versioned;
|
||||
|
||||
if opts.version_id.is_some() {
|
||||
|
||||
@@ -32,6 +32,8 @@ use crate::crash_inject::{self, CrashPoint};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use futures::{StreamExt, stream};
|
||||
use std::future::Future;
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::AtomicBool;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -65,6 +67,7 @@ impl StaleMultipartCleanupGuard {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MultipartCommitPause {
|
||||
NewUploadBeforeLockLost,
|
||||
PutPartBeforeLockAcquire,
|
||||
PutPartBeforeLockLost,
|
||||
PutPartAfterRename,
|
||||
@@ -156,6 +159,72 @@ impl Drop for MultipartCommitBarrier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct NewMultipartUploadCommitObservationState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
committed: AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct NewMultipartUploadCommitObservation {
|
||||
state: Arc<NewMultipartUploadCommitObservationState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<NewMultipartUploadCommitObservationState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl NewMultipartUploadCommitObservation {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
let state = Arc::new(NewMultipartUploadCommitObservationState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
committed: AtomicBool::new(false),
|
||||
});
|
||||
let mut slot = NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("new multipart upload commit observation mutex should not poison");
|
||||
assert!(slot.is_none(), "new multipart upload commit observation must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) fn committed(&self) -> bool {
|
||||
self.state.committed.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for NewMultipartUploadCommitObservation {
|
||||
fn drop(&mut self) {
|
||||
let mut slot = NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("new multipart upload commit observation mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn observe_new_multipart_upload_commit(bucket: &str, object: &str) {
|
||||
let state = NEW_MULTIPART_UPLOAD_COMMIT_OBSERVATION
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("new multipart upload commit observation mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket && state.object == object)
|
||||
.cloned();
|
||||
if let Some(state) = state {
|
||||
state.committed.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) {
|
||||
let barrier = {
|
||||
@@ -1615,6 +1684,30 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
|
||||
let upload_path = Self::get_multipart_upload_dir(bucket, object, upload_uuid.as_str(), opts.data_movement);
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_multipart_commit(bucket, object, MultipartCommitPause::NewUploadBeforeLockLost).await;
|
||||
if _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "new_multipart_upload_commit",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
if opts
|
||||
.namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "new_multipart_upload_outer_lock",
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
ensure_multipart_bucket_lifecycle_lock_held(bucket, object, opts)?;
|
||||
Self::write_unique_file_info(
|
||||
&shuffle_disks,
|
||||
@@ -1626,6 +1719,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e.into(), vec![bucket, object]))?;
|
||||
#[cfg(test)]
|
||||
observe_new_multipart_upload_commit(bucket, object);
|
||||
|
||||
// evalDisks
|
||||
|
||||
|
||||
@@ -2483,6 +2483,7 @@ impl SetDisks {
|
||||
})
|
||||
.await?,
|
||||
);
|
||||
notify_put_object_commit_namespace_acquired(bucket, object);
|
||||
}
|
||||
#[cfg(not(any(test, feature = "test-util")))]
|
||||
{
|
||||
@@ -4630,6 +4631,7 @@ struct PutObjectCommitBarrierState {
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
namespace_pending: tokio::sync::Notify,
|
||||
namespace_acquired: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
@@ -4651,6 +4653,7 @@ impl PutObjectCommitBarrier {
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
namespace_pending: tokio::sync::Notify::new(),
|
||||
namespace_acquired: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
let mut slot = PUT_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
@@ -4685,6 +4688,10 @@ impl PutObjectCommitBarrier {
|
||||
.await
|
||||
.expect("put object should wait for the namespace lock after leaving the commit barrier");
|
||||
}
|
||||
|
||||
pub fn namespace_acquired(&self) -> bool {
|
||||
self.state.namespace_acquired.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
@@ -4741,6 +4748,22 @@ fn notify_put_object_commit_namespace_pending(bucket: &str, object: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn notify_put_object_commit_namespace_acquired(bucket: &str, object: &str) {
|
||||
let barrier = PUT_OBJECT_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("put object commit barrier mutex should not poison")
|
||||
.iter()
|
||||
.find(|barrier| {
|
||||
barrier.bucket == bucket && barrier.object == object && barrier.pause == PutObjectCommitPause::BeforeNamespace
|
||||
})
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.namespace_acquired.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DeleteObjectCommitBarrierState {
|
||||
bucket: String,
|
||||
@@ -4750,7 +4773,7 @@ struct DeleteObjectCommitBarrierState {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DeleteObjectCommitBarrier {
|
||||
pub(crate) struct DeleteObjectCommitBarrier {
|
||||
state: Arc<DeleteObjectCommitBarrierState>,
|
||||
}
|
||||
|
||||
@@ -4760,7 +4783,7 @@ static DELETE_OBJECT_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option
|
||||
|
||||
#[cfg(test)]
|
||||
impl DeleteObjectCommitBarrier {
|
||||
fn install(bucket: &str, object: &str) -> Self {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
let state = Arc::new(DeleteObjectCommitBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
@@ -4776,13 +4799,13 @@ impl DeleteObjectCommitBarrier {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
async fn wait_until_paused(&self) {
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("delete object should reach the deterministic commit barrier");
|
||||
}
|
||||
|
||||
fn release(&self) {
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
@@ -5877,6 +5900,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
if dobj.version_id.is_none() && (version_suspended || versioned) {
|
||||
vr.mod_time = Some(OffsetDateTime::now_utc());
|
||||
vr.deleted = true;
|
||||
vr.mark_deleted = true;
|
||||
if versioned {
|
||||
vr.version_id = Some(Uuid::new_v4());
|
||||
}
|
||||
@@ -11636,6 +11660,7 @@ mod transition_upload_integrity_tests {
|
||||
crate::data_movement::SourceCleanupBucketFence {
|
||||
expected_incarnation_id: None,
|
||||
lifecycle_guard: Some(&bucket_guard),
|
||||
..Default::default()
|
||||
},
|
||||
"test_data_movement",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -151,7 +151,7 @@ pub(crate) mod init_format;
|
||||
pub(crate) mod list_objects;
|
||||
mod multipart;
|
||||
mod object;
|
||||
pub(crate) use object::ObjectLockDiagGuard;
|
||||
pub(crate) use object::{ObjectLockDiagGuard, SourceCleanupMutationFence};
|
||||
pub use object::{
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
SnapshotConsistencyError,
|
||||
|
||||
@@ -400,7 +400,7 @@ impl ECStore {
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<MultipartUploadResult> {
|
||||
self.handle_new_multipart_upload_with_pool_idx(bucket, object, opts)
|
||||
self.handle_new_multipart_upload_with_pool_idx(bucket, object, opts, None)
|
||||
.await
|
||||
.map(|(res, _, _)| res)
|
||||
}
|
||||
@@ -410,20 +410,22 @@ impl ECStore {
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
||||
) -> Result<(MultipartUploadResult, usize, Option<Uuid>)> {
|
||||
check_new_multipart_args(bucket, object)?;
|
||||
let (opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
let opts = &opts;
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
|
||||
if self.single_pool() {
|
||||
self.apply_decommission_target_mutation_fence(0, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
return self.pools[0]
|
||||
.new_multipart_upload(bucket, object, opts)
|
||||
.new_multipart_upload(bucket, object, &opts)
|
||||
.await
|
||||
.map(|res| (res, 0, opts.expected_bucket_incarnation_id));
|
||||
}
|
||||
|
||||
if opts.data_movement && opts.version_id.is_some() {
|
||||
let idx = self.select_data_movement_pool_idx(bucket, object, -1, opts, false).await?;
|
||||
let idx = self.select_data_movement_pool_idx(bucket, object, -1, &opts, false).await?;
|
||||
if idx == opts.src_pool_idx {
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
@@ -431,7 +433,9 @@ impl ECStore {
|
||||
opts.version_id.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, &opts).await?;
|
||||
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
||||
}
|
||||
|
||||
@@ -454,7 +458,9 @@ impl ECStore {
|
||||
.await?;
|
||||
|
||||
if !res.uploads.is_empty() {
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, &opts).await?;
|
||||
return Ok((res, idx, opts.expected_bucket_incarnation_id));
|
||||
}
|
||||
}
|
||||
@@ -467,7 +473,9 @@ impl ECStore {
|
||||
));
|
||||
}
|
||||
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, opts).await?;
|
||||
self.apply_decommission_target_mutation_fence(idx, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
let res = self.pools[idx].new_multipart_upload(bucket, object, &opts).await?;
|
||||
Ok((res, idx, opts.expected_bucket_incarnation_id))
|
||||
}
|
||||
|
||||
@@ -704,13 +712,14 @@ impl ECStore {
|
||||
|
||||
pub(crate) async fn complete_multipart_upload_for_data_movement(
|
||||
self: Arc<Self>,
|
||||
target_pool_idx: usize,
|
||||
target: (usize, Option<&ObjectLockDiagGuard>),
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
upload_id: &str,
|
||||
uploaded_parts: Vec<CompletePart>,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ObjectInfo> {
|
||||
let (target_pool_idx, mutation_fence) = target;
|
||||
check_complete_multipart_args(bucket, object, upload_id)?;
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("targeted multipart completion requires data_movement options"));
|
||||
@@ -739,6 +748,8 @@ impl ECStore {
|
||||
snapshot.add_lock_fences(&mut opts);
|
||||
opts.object_lock_config_snapshot = Some(snapshot);
|
||||
}
|
||||
self.apply_decommission_target_mutation_fence(target_pool_idx, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
pause_data_movement_multipart_before_selected_completion(bucket).await;
|
||||
let pool = self
|
||||
|
||||
@@ -32,12 +32,13 @@ use crate::bucket::metadata_sys::{
|
||||
use crate::bucket::object_lock::objectlock_sys::{
|
||||
check_object_lock_for_deletion_with_state, ensure_recursive_force_delete_allowed_for_state,
|
||||
};
|
||||
use crate::bucket::replication::ReplicationObjectBridge;
|
||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||
use crate::bucket::versioning::VersioningApi;
|
||||
use crate::disk::OldCurrentSize;
|
||||
use crate::object_api::{NamespaceLockFence, ObjectLockConfigSnapshot};
|
||||
use crate::set_disk::{
|
||||
get_lock_acquire_timeout, get_object_lock_diag_slow_acquire_threshold, get_object_lock_diag_slow_hold_threshold,
|
||||
is_lock_optimization_enabled, is_object_lock_diag_enabled,
|
||||
SetDisks, get_lock_acquire_timeout, get_object_lock_diag_slow_acquire_threshold, get_object_lock_diag_slow_hold_threshold,
|
||||
is_lock_optimization_enabled, is_object_lock_diag_enabled, same_distributed_lock_domain,
|
||||
};
|
||||
use crate::storage_api_contracts::{
|
||||
namespace::NamespaceLocking as _,
|
||||
@@ -352,6 +353,8 @@ impl fmt::Display for ObjectLockDiagMode {
|
||||
|
||||
pub(crate) struct ObjectLockDiagGuard {
|
||||
guard: rustfs_lock::NamespaceLockGuard,
|
||||
#[cfg(test)]
|
||||
test_namespace_lock_fence: Option<NamespaceLockFence>,
|
||||
enabled: bool,
|
||||
op: &'static str,
|
||||
bucket: Option<String>,
|
||||
@@ -373,6 +376,8 @@ impl ObjectLockDiagGuard {
|
||||
) -> Self {
|
||||
Self {
|
||||
guard,
|
||||
#[cfg(test)]
|
||||
test_namespace_lock_fence: None,
|
||||
enabled,
|
||||
op,
|
||||
bucket,
|
||||
@@ -393,6 +398,115 @@ impl ObjectLockDiagGuard {
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
self.guard.is_lock_lost()
|
||||
}
|
||||
|
||||
pub(crate) fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
||||
opts.ensure_namespace_lock_fence();
|
||||
if let Some(signal) = self.lock_lost_signal() {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(fence) = self.test_namespace_lock_fence.as_ref() {
|
||||
opts.add_namespace_lock_fence_for_test(fence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DecommissionMutationFenceTestPhase {
|
||||
Migration,
|
||||
SourceCleanup,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct DecommissionMutationFenceLossState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
phase: DecommissionMutationFenceTestPhase,
|
||||
fence: NamespaceLockFence,
|
||||
loss_handle: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct DecommissionMutationFenceLossHook {
|
||||
state: Arc<DecommissionMutationFenceLossState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static DECOMMISSION_MUTATION_FENCE_LOSS_HOOK: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<DecommissionMutationFenceLossState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl DecommissionMutationFenceLossHook {
|
||||
pub(crate) fn install(bucket: &str, object: &str, phase: DecommissionMutationFenceTestPhase) -> Self {
|
||||
let (fence, loss_handle) = NamespaceLockFence::loss_handle_for_test();
|
||||
let state = Arc::new(DecommissionMutationFenceLossState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
phase,
|
||||
fence,
|
||||
loss_handle,
|
||||
});
|
||||
let mut slot = DECOMMISSION_MUTATION_FENCE_LOSS_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("decommission mutation fence loss hooks should not poison");
|
||||
assert!(slot.is_none(), "decommission mutation fence loss hook must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) fn mark_lost(&self) {
|
||||
self.state.loss_handle.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for DecommissionMutationFenceLossHook {
|
||||
fn drop(&mut self) {
|
||||
let mut slot = DECOMMISSION_MUTATION_FENCE_LOSS_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("decommission mutation fence loss hooks should not poison");
|
||||
if slot.as_ref().is_some_and(|hook| Arc::ptr_eq(hook, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn decommission_mutation_fence_for_test(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
phase: DecommissionMutationFenceTestPhase,
|
||||
) -> Option<NamespaceLockFence> {
|
||||
DECOMMISSION_MUTATION_FENCE_LOSS_HOOK
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("decommission mutation fence loss hooks should not poison")
|
||||
.as_ref()
|
||||
.filter(|hook| hook.bucket == bucket && hook.object == object && hook.phase == phase)
|
||||
.map(|hook| hook.fence.clone())
|
||||
}
|
||||
|
||||
pub(crate) struct SourceCleanupMutationFence {
|
||||
guard: ObjectLockDiagGuard,
|
||||
source_lock_covered: bool,
|
||||
}
|
||||
|
||||
impl SourceCleanupMutationFence {
|
||||
pub(crate) fn source_lock_covered(&self) -> bool {
|
||||
self.source_lock_covered
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
self.guard.is_lock_lost()
|
||||
}
|
||||
|
||||
pub(crate) fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
||||
self.guard.add_namespace_lock_fence(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque write-lock guard for the RestoreObject accept path; see
|
||||
@@ -410,10 +524,7 @@ impl RestoreAcceptGuard {
|
||||
}
|
||||
|
||||
pub fn add_namespace_lock_fence(&self, opts: &mut ObjectOptions) {
|
||||
opts.ensure_namespace_lock_fence();
|
||||
if let Some(signal) = self.0.lock_lost_signal() {
|
||||
opts.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
self.0.add_namespace_lock_fence(opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,16 +801,6 @@ impl SelectObjectSnapshotLockLossWake {
|
||||
}
|
||||
}
|
||||
|
||||
// LockRegistry clones its canonical client Arc for each endpoint host, so an
|
||||
// exact Arc set identifies one distributed namespace-lock quorum domain.
|
||||
fn same_distributed_lock_domain(left: &[Arc<dyn rustfs_lock::LockClient>], right: &[Arc<dyn rustfs_lock::LockClient>]) -> bool {
|
||||
left.iter()
|
||||
.all(|left_client| right.iter().any(|right_client| Arc::ptr_eq(left_client, right_client)))
|
||||
&& right
|
||||
.iter()
|
||||
.all(|right_client| left.iter().any(|left_client| Arc::ptr_eq(left_client, right_client)))
|
||||
}
|
||||
|
||||
impl AsyncRead for SelectObjectSnapshotReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if self.lock_loss_wake.poll_lost(cx) || self.lease.is_lost() {
|
||||
@@ -805,7 +906,7 @@ fn resolve_latest_object_access(
|
||||
}
|
||||
|
||||
fn should_create_delete_marker_for_missing_object(opts: &ObjectOptions) -> bool {
|
||||
opts.versioned && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
|
||||
(opts.versioned || opts.version_suspended) && opts.version_id.is_none() && !opts.delete_marker && !opts.data_movement
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -813,6 +914,8 @@ struct DeleteAfterObjectLockSnapshotBarrierState {
|
||||
bucket: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
namespace_pending: tokio::sync::Notify,
|
||||
namespace_acquired: AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -832,6 +935,8 @@ impl DeleteAfterObjectLockSnapshotBarrier {
|
||||
bucket: bucket.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
namespace_pending: tokio::sync::Notify::new(),
|
||||
namespace_acquired: AtomicBool::new(false),
|
||||
});
|
||||
let mut slot = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
@@ -849,6 +954,18 @@ impl DeleteAfterObjectLockSnapshotBarrier {
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
|
||||
pub(crate) async fn release_and_wait_until_namespace_pending(&self) {
|
||||
let namespace_pending = self.state.namespace_pending.notified();
|
||||
self.release();
|
||||
tokio::time::timeout(Duration::from_secs(5), namespace_pending)
|
||||
.await
|
||||
.expect("delete should proceed to its namespace lock after leaving the snapshot barrier");
|
||||
}
|
||||
|
||||
pub(crate) fn namespace_acquired(&self) -> bool {
|
||||
self.state.namespace_acquired.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -873,6 +990,97 @@ async fn pause_delete_after_object_lock_snapshot(bucket: &str) {
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket)
|
||||
.cloned();
|
||||
if let Some(state) = state {
|
||||
state.arrived.notify_one();
|
||||
state.release.notified().await;
|
||||
state.namespace_pending.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn notify_delete_namespace_acquired(bucket: &str) {
|
||||
let state = DELETE_AFTER_OBJECT_LOCK_SNAPSHOT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("delete snapshot barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket)
|
||||
.cloned();
|
||||
if let Some(state) = state {
|
||||
state.namespace_acquired.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct VersionedDeleteMarkerCommitBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct VersionedDeleteMarkerCommitBarrier {
|
||||
state: Arc<VersionedDeleteMarkerCommitBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static VERSIONED_DELETE_MARKER_COMMIT_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<VersionedDeleteMarkerCommitBarrierState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl VersionedDeleteMarkerCommitBarrier {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
let state = Arc::new(VersionedDeleteMarkerCommitBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = VERSIONED_DELETE_MARKER_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("versioned delete-marker commit barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "versioned delete-marker commit barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("versioned DELETE should reach the post-marker-commit barrier");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for VersionedDeleteMarkerCommitBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = VERSIONED_DELETE_MARKER_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("versioned delete-marker commit barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_versioned_delete_marker_after_commit(bucket: &str, object: &str) {
|
||||
let state = VERSIONED_DELETE_MARKER_COMMIT_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("versioned delete-marker commit barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket && state.object == object)
|
||||
.cloned();
|
||||
if let Some(state) = state {
|
||||
state.arrived.notify_one();
|
||||
state.release.notified().await;
|
||||
@@ -913,6 +1121,160 @@ fn writer_pool_lookup_opts(opts: &ObjectOptions, no_lock: bool) -> ObjectOptions
|
||||
lookup_opts
|
||||
}
|
||||
|
||||
fn delete_pool_lookup_opts(opts: &ObjectOptions, no_lock: bool) -> ObjectOptions {
|
||||
let mut lookup_opts = writer_pool_lookup_opts(opts, no_lock);
|
||||
lookup_opts.skip_decommissioned = opts.data_movement;
|
||||
lookup_opts
|
||||
}
|
||||
|
||||
fn should_delete_from_all_pools(opts: &ObjectOptions, pool_count: usize) -> bool {
|
||||
pool_count > 0 && (!opts.versioned && !opts.version_suspended || opts.version_id.is_some())
|
||||
}
|
||||
|
||||
fn batch_delete_creates_latest_marker(object: &ObjectToDelete, delete_config_snapshot: &DeleteReplicationConfigSnapshot) -> bool {
|
||||
if object.version_id.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let object_name = decode_dir_object(&object.object_name);
|
||||
let (versioned, version_suspended) = delete_config_snapshot.versioning_config().delete_state(&object_name);
|
||||
versioned || version_suspended
|
||||
}
|
||||
|
||||
fn batch_delete_targets_pool(creates_latest_marker: bool, marker_target_pool_idx: Option<usize>, pool_idx: usize) -> bool {
|
||||
!creates_latest_marker || marker_target_pool_idx == Some(pool_idx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct BatchDeletePoolErrorInjectionState {
|
||||
bucket: String,
|
||||
pool_idx: usize,
|
||||
errors: std::collections::HashMap<String, Error>,
|
||||
observed: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct BatchDeletePoolErrorInjection {
|
||||
state: Arc<BatchDeletePoolErrorInjectionState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static BATCH_DELETE_POOL_ERROR_INJECTION: std::sync::OnceLock<std::sync::Mutex<Option<Arc<BatchDeletePoolErrorInjectionState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl BatchDeletePoolErrorInjection {
|
||||
pub(crate) fn install(bucket: &str, pool_idx: usize, errors: Vec<(String, Error)>) -> Self {
|
||||
let state = Arc::new(BatchDeletePoolErrorInjectionState {
|
||||
bucket: bucket.to_string(),
|
||||
pool_idx,
|
||||
errors: errors.into_iter().collect(),
|
||||
observed: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
let mut slot = BATCH_DELETE_POOL_ERROR_INJECTION
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("batch delete pool error injection mutex should not poison");
|
||||
assert!(slot.is_none(), "batch delete pool error injection must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) fn observed(&self) -> usize {
|
||||
self.state.observed.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for BatchDeletePoolErrorInjection {
|
||||
fn drop(&mut self) {
|
||||
let mut slot = BATCH_DELETE_POOL_ERROR_INJECTION
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("batch delete pool error injection mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn inject_batch_delete_pool_errors(
|
||||
bucket: &str,
|
||||
pool_idx: usize,
|
||||
object_names: &[String],
|
||||
result: &mut (Vec<DeletedObject>, Vec<Option<Error>>),
|
||||
) {
|
||||
let state = BATCH_DELETE_POOL_ERROR_INJECTION
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("batch delete pool error injection mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|state| state.bucket == bucket && state.pool_idx == pool_idx)
|
||||
.cloned();
|
||||
let Some(state) = state else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (idx, object_name) in object_names.iter().enumerate() {
|
||||
let Some(error) = state.errors.get(object_name) else {
|
||||
continue;
|
||||
};
|
||||
if result.1[idx].is_none() && result.0[idx].found {
|
||||
result.1[idx] = Some(error.clone());
|
||||
state.observed.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_batch_delete_pool_results<'a>(
|
||||
initial_error: Option<Error>,
|
||||
pool_results: impl IntoIterator<Item = (&'a DeletedObject, &'a Option<Error>)>,
|
||||
) -> (Option<DeletedObject>, Option<Error>, bool) {
|
||||
let mut failure = initial_error.map(|err| (None, err));
|
||||
let mut deleted = None;
|
||||
let mut fallback: Option<(DeletedObject, Option<Error>)> = None;
|
||||
let mut attempted = false;
|
||||
|
||||
for (pool_delete, pool_error) in pool_results {
|
||||
attempted = true;
|
||||
match pool_error {
|
||||
Some(err) if is_err_object_not_found(err) || is_err_version_not_found(err) => {
|
||||
if fallback.as_ref().is_none_or(|(_, error)| error.is_none()) {
|
||||
fallback = Some(((*pool_delete).clone(), Some(err.clone())));
|
||||
}
|
||||
}
|
||||
Some(err) => {
|
||||
if failure.is_none() {
|
||||
failure = Some((Some((*pool_delete).clone()), err.clone()));
|
||||
}
|
||||
}
|
||||
None if pool_delete.found => {
|
||||
if deleted.is_none() {
|
||||
deleted = Some((*pool_delete).clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if fallback.is_none() {
|
||||
fallback = Some(((*pool_delete).clone(), None));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((failed_delete, err)) = failure {
|
||||
return (failed_delete, Some(err), attempted);
|
||||
}
|
||||
if let Some(deleted) = deleted {
|
||||
return (Some(deleted), None, attempted);
|
||||
}
|
||||
if let Some((deleted, err)) = fallback {
|
||||
return (Some(deleted), err, attempted);
|
||||
}
|
||||
|
||||
(None, None, attempted)
|
||||
}
|
||||
|
||||
fn transition_restore_pool_opts(opts: &ObjectOptions) -> ObjectOptions {
|
||||
let mut lookup_opts = opts.clone();
|
||||
lookup_opts.skip_decommissioned = true;
|
||||
@@ -1533,6 +1895,89 @@ impl ECStore {
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_decommission_object_mutation_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
) -> Result<ObjectLockDiagGuard> {
|
||||
if self.ctx.lock_manager().is_disabled() {
|
||||
return Err(Error::other("decommission object migration requires namespace locking"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let test_namespace_lock_fence =
|
||||
decommission_mutation_fence_for_test(bucket, object, DecommissionMutationFenceTestPhase::Migration);
|
||||
let object = encode_dir_object(object);
|
||||
let mut opts = ObjectOptions::default();
|
||||
let guard = self
|
||||
.acquire_object_read_lock_if_needed("decommission_object", bucket, &object, &mut opts)
|
||||
.await?
|
||||
.ok_or_else(|| Error::other("decommission object migration failed to acquire its namespace fence"))?;
|
||||
#[cfg(test)]
|
||||
let guard = {
|
||||
let mut guard = guard;
|
||||
guard.test_namespace_lock_fence = test_namespace_lock_fence;
|
||||
guard
|
||||
};
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
pub(super) async fn apply_decommission_target_mutation_fence(
|
||||
&self,
|
||||
target_pool_idx: usize,
|
||||
object: &str,
|
||||
opts: &mut ObjectOptions,
|
||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
||||
) {
|
||||
let Some(mutation_fence) = mutation_fence else {
|
||||
return;
|
||||
};
|
||||
|
||||
mutation_fence.add_namespace_lock_fence(opts);
|
||||
let fixed_set = self.pools.first().and_then(|pool| pool.disk_set.first());
|
||||
let target_set = self.pools.get(target_pool_idx).map(|pool| pool.get_disks_by_key(object));
|
||||
opts.no_lock = match (fixed_set, target_set) {
|
||||
(Some(fixed), Some(target)) => fixed.shares_namespace_lock_domain(&target).await,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_decommission_source_cleanup_fence(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
source_set: &SetDisks,
|
||||
) -> Result<SourceCleanupMutationFence> {
|
||||
if self.ctx.lock_manager().is_disabled() {
|
||||
return Err(Error::other("decommission source cleanup requires namespace locking"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
crate::data_movement::notify_source_cleanup_mutation_fence_pending(bucket, object);
|
||||
#[cfg(test)]
|
||||
let test_namespace_lock_fence =
|
||||
decommission_mutation_fence_for_test(bucket, object, DecommissionMutationFenceTestPhase::SourceCleanup);
|
||||
let object = encode_dir_object(object);
|
||||
let fixed_set = Arc::clone(&self.pools[0].disk_set[0]);
|
||||
let source_lock_covered = fixed_set.shares_namespace_lock_domain(source_set).await;
|
||||
// Lock order: fixed store mutation domain first; source cleanup takes its
|
||||
// hashed source-domain lock second only when this guard does not cover it.
|
||||
let guard = self
|
||||
.acquire_object_write_lock("decommission_source_cleanup", bucket, &object)
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
let guard = {
|
||||
let mut guard = guard;
|
||||
guard.test_namespace_lock_fence = test_namespace_lock_fence;
|
||||
guard
|
||||
};
|
||||
|
||||
Ok(SourceCleanupMutationFence {
|
||||
guard,
|
||||
source_lock_covered,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_all_object_read_locks(
|
||||
&self,
|
||||
op: &'static str,
|
||||
@@ -1986,14 +2431,17 @@ impl ECStore {
|
||||
object: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
mutation_fence: Option<&ObjectLockDiagGuard>,
|
||||
) -> Result<(usize, Result<ObjectInfo>)> {
|
||||
if !opts.data_movement {
|
||||
return Err(Error::other("data movement PUT requires data_movement options"));
|
||||
}
|
||||
let (object, opts) = self.prepare_put_object(bucket, object, opts).await?;
|
||||
let (object, mut opts) = self.prepare_put_object(bucket, object, opts).await?;
|
||||
let idx = self
|
||||
.select_put_object_pool_idx(bucket, object.as_str(), data.size(), &opts)
|
||||
.await?;
|
||||
self.apply_decommission_target_mutation_fence(idx, object.as_str(), &mut opts, mutation_fence)
|
||||
.await;
|
||||
let result = self.pools[idx]
|
||||
.put_object_with_old_current_size(bucket, &object, data, &opts)
|
||||
.await
|
||||
@@ -2446,6 +2894,10 @@ impl ECStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(test)]
|
||||
if _object_lock_guard.is_some() {
|
||||
notify_delete_namespace_acquired(bucket);
|
||||
}
|
||||
if let Some(trigger) = opts.lifecycle_delete_all.as_ref() {
|
||||
let configs = delete_all_configs.as_ref().ok_or(StorageError::PreconditionFailed)?;
|
||||
let expected_bucket_incarnation_id = opts.expected_bucket_incarnation_id.ok_or(StorageError::PreconditionFailed)?;
|
||||
@@ -2479,7 +2931,7 @@ impl ECStore {
|
||||
return Ok(ObjectInfo::default());
|
||||
}
|
||||
|
||||
let gopts = writer_pool_lookup_opts(&opts, true);
|
||||
let gopts = delete_pool_lookup_opts(&opts, true);
|
||||
|
||||
if opts.data_movement {
|
||||
let existing_pool_info = self.get_pool_info_existing_with_opts(bucket, object, &gopts).await;
|
||||
@@ -2584,6 +3036,8 @@ impl ECStore {
|
||||
Err(err) if is_err_object_not_found(&err) && should_create_delete_marker_for_missing_object(&opts) => {
|
||||
let target_pool_idx = self.get_pool_idx_no_lock(bucket, object, 0).await?;
|
||||
let mut obj = self.pools[target_pool_idx].delete_object(bucket, object, opts).await?;
|
||||
#[cfg(test)]
|
||||
pause_versioned_delete_marker_after_commit(bucket, object).await;
|
||||
obj.name = decode_dir_object(object);
|
||||
return Ok(obj);
|
||||
}
|
||||
@@ -2622,7 +3076,7 @@ impl ECStore {
|
||||
None
|
||||
};
|
||||
|
||||
if !errs.is_empty() && !opts.versioned && !opts.version_suspended {
|
||||
if should_delete_from_all_pools(&opts, errs.len()) {
|
||||
let mut obj = match self.delete_object_from_all_pools(bucket, object, &opts, errs).await {
|
||||
Ok(obj) => obj,
|
||||
Err(err) => {
|
||||
@@ -2646,6 +3100,8 @@ impl ECStore {
|
||||
|
||||
match pool.delete_object(bucket, object, opts.clone()).await {
|
||||
Ok(res) => {
|
||||
#[cfg(test)]
|
||||
pause_versioned_delete_marker_after_commit(bucket, object).await;
|
||||
if let (Some(api), Some(je)) = (tier_journal_api.as_ref(), journal_entry.as_ref()) {
|
||||
commit_prepared_tier_delete_journal_entry(api, je).await;
|
||||
}
|
||||
@@ -2776,30 +3232,104 @@ impl ECStore {
|
||||
Ok(guards) => guards,
|
||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
||||
};
|
||||
#[cfg(test)]
|
||||
if !_object_lock_guards.is_empty() {
|
||||
notify_delete_namespace_acquired(bucket);
|
||||
}
|
||||
|
||||
let delete_config_snapshot = opts
|
||||
.delete_replication_config_snapshot
|
||||
.as_deref()
|
||||
.expect("batch delete replication config snapshot should be loaded");
|
||||
let latest_marker_objects = objects
|
||||
.iter()
|
||||
.map(|object| batch_delete_creates_latest_marker(object, delete_config_snapshot))
|
||||
.collect::<Vec<_>>();
|
||||
let marker_target_results = join_all(objects.iter().zip(&latest_marker_objects).map(
|
||||
|(object, creates_marker)| async move {
|
||||
if *creates_marker {
|
||||
Some(self.get_pool_idx_no_lock(bucket, &object.object_name, 0).await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
))
|
||||
.await;
|
||||
let mut marker_target_pool_indices = Vec::with_capacity(objects.len());
|
||||
for (idx, target_result) in marker_target_results.into_iter().enumerate() {
|
||||
match target_result {
|
||||
Some(Ok(pool_idx)) => marker_target_pool_indices.push(Some(pool_idx)),
|
||||
Some(Err(err)) => {
|
||||
del_errs[idx] = Some(err);
|
||||
marker_target_pool_indices.push(None);
|
||||
}
|
||||
None => marker_target_pool_indices.push(None),
|
||||
}
|
||||
}
|
||||
|
||||
let mut futures = Vec::with_capacity(self.pools.len());
|
||||
|
||||
for pool in self.pools.iter() {
|
||||
if self.is_pool_rebalancing(pool.pool_idx).await {
|
||||
continue;
|
||||
}
|
||||
futures.push(pool.delete_objects(bucket, objects.clone(), opts.clone()));
|
||||
|
||||
let (object_indices, pool_objects): (Vec<_>, Vec<_>) = objects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
batch_delete_targets_pool(latest_marker_objects[*idx], marker_target_pool_indices[*idx], pool.pool_idx)
|
||||
})
|
||||
.map(|(idx, object)| (idx, object.clone()))
|
||||
.unzip();
|
||||
if pool_objects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pool_opts = opts.clone();
|
||||
futures.push(async move {
|
||||
#[cfg(test)]
|
||||
let pool_object_names = pool_objects
|
||||
.iter()
|
||||
.map(|object| object.object_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let result = pool.delete_objects(bucket, pool_objects, pool_opts).await;
|
||||
#[cfg(test)]
|
||||
let result = {
|
||||
let mut result = result;
|
||||
inject_batch_delete_pool_errors(bucket, pool.pool_idx, &pool_object_names, &mut result);
|
||||
result
|
||||
};
|
||||
(object_indices, result)
|
||||
});
|
||||
}
|
||||
|
||||
let results = join_all(futures).await;
|
||||
|
||||
for idx in 0..del_objects.len() {
|
||||
for (dels, errs) in results.iter() {
|
||||
if errs[idx].is_none() && dels[idx].found {
|
||||
del_errs[idx] = None;
|
||||
del_objects[idx] = dels[idx].clone();
|
||||
break;
|
||||
}
|
||||
let pool_results = results.iter().filter_map(|(object_indices, (dels, errs))| {
|
||||
let pool_object_idx = object_indices.binary_search(&idx).ok()?;
|
||||
Some((&dels[pool_object_idx], &errs[pool_object_idx]))
|
||||
});
|
||||
let (deleted, error, attempted) = resolve_batch_delete_pool_results(del_errs[idx].take(), pool_results);
|
||||
if let Some(deleted) = deleted {
|
||||
del_objects[idx] = deleted;
|
||||
}
|
||||
del_errs[idx] = error;
|
||||
|
||||
if del_errs[idx].is_none() {
|
||||
del_errs[idx] = errs[idx].clone();
|
||||
del_objects[idx] = dels[idx].clone();
|
||||
}
|
||||
if !attempted && del_errs[idx].is_none() && latest_marker_objects[idx] {
|
||||
del_objects[idx] = DeletedObject {
|
||||
object_name: objects[idx].object_name.clone(),
|
||||
version_id: objects[idx].version_id,
|
||||
..Default::default()
|
||||
};
|
||||
del_errs[idx] = Some(StorageError::ObjectNotFound(bucket.to_owned(), objects[idx].object_name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
for (idx, object) in objects.iter().enumerate() {
|
||||
if del_errs[idx].is_none() && del_objects[idx].delete_marker {
|
||||
pause_versioned_delete_marker_after_commit(bucket, &object.object_name).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3374,6 +3904,80 @@ mod tests {
|
||||
assert!(!same_distributed_lock_domain(&[first, second], &[other]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decommission_fence_covers_dist_sets_with_same_clients_despite_different_namespaces() {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (_dirs, original_sets) = make_local_two_set_sets_with_ctx(Arc::clone(&ctx)).await;
|
||||
let mut second_set = (*original_sets.disk_set[1]).clone();
|
||||
second_set.lockers = original_sets.disk_set[0].lockers.clone();
|
||||
let mut sets = (*original_sets).clone();
|
||||
sets.disk_set[1] = Arc::new(second_set);
|
||||
let sets = Arc::new(sets);
|
||||
ctx.update_erasure_type(SetupType::DistErasure).await;
|
||||
|
||||
assert!(
|
||||
sets.disk_set[0]
|
||||
.lockers
|
||||
.iter()
|
||||
.zip(&sets.disk_set[1].lockers)
|
||||
.all(|(fixed, hashed)| Arc::ptr_eq(fixed, hashed)),
|
||||
"the regression requires identical distributed lock clients"
|
||||
);
|
||||
assert_ne!(sets.disk_set[0].set_index, sets.disk_set[1].set_index);
|
||||
|
||||
let pool_config = sets.endpoints.clone();
|
||||
let store = new_prepared_reader_test_store_from_pools(vec![Arc::clone(&sets)], vec![pool_config], ctx);
|
||||
let object = (0..1_000)
|
||||
.map(|index| format!("decommission-dist-domain-{index}.bin"))
|
||||
.find(|candidate| Arc::ptr_eq(&sets.get_disks_by_key(candidate), &sets.disk_set[1]))
|
||||
.expect("a key should hash to the second set namespace");
|
||||
let mutation_fence = store
|
||||
.acquire_decommission_object_mutation_fence("bucket", &object)
|
||||
.await
|
||||
.expect("the fixed distributed mutation fence should be acquired");
|
||||
let target_lock = sets.disk_set[1]
|
||||
.new_ns_lock("bucket", &object)
|
||||
.await
|
||||
.expect("the hashed-set namespace lock should be created");
|
||||
let target_err = target_lock
|
||||
.get_write_lock(Duration::from_millis(50))
|
||||
.await
|
||||
.expect_err("the fixed read fence must conflict through the shared clients");
|
||||
assert!(matches!(target_err, rustfs_lock::LockError::Timeout { .. }));
|
||||
|
||||
let mut put_opts = ObjectOptions::default();
|
||||
store
|
||||
.apply_decommission_target_mutation_fence(0, &object, &mut put_opts, Some(&mutation_fence))
|
||||
.await;
|
||||
assert!(put_opts.no_lock, "migration target PUT must reuse the covering fixed fence");
|
||||
|
||||
let mut multipart_opts = ObjectOptions::default();
|
||||
store
|
||||
.apply_decommission_target_mutation_fence(0, &object, &mut multipart_opts, Some(&mutation_fence))
|
||||
.await;
|
||||
assert!(multipart_opts.no_lock, "migration target multipart must reuse the covering fixed fence");
|
||||
drop(mutation_fence);
|
||||
|
||||
let cleanup_object = (0..1_000)
|
||||
.map(|index| format!("decommission-dist-cleanup-{index}.bin"))
|
||||
.find(|candidate| Arc::ptr_eq(&sets.get_disks_by_key(candidate), &sets.disk_set[1]))
|
||||
.expect("a cleanup key should hash to the second set namespace");
|
||||
let source_fence = store
|
||||
.acquire_decommission_source_cleanup_fence("bucket", &cleanup_object, sets.disk_set[1].as_ref())
|
||||
.await
|
||||
.expect("the fixed distributed cleanup fence should be acquired");
|
||||
assert!(source_fence.source_lock_covered(), "source cleanup must reuse the covering fixed fence");
|
||||
let source_lock = sets.disk_set[1]
|
||||
.new_ns_lock("bucket", &cleanup_object)
|
||||
.await
|
||||
.expect("the source-set namespace lock should be created");
|
||||
let source_err = source_lock
|
||||
.get_read_lock(Duration::from_millis(50))
|
||||
.await
|
||||
.expect_err("the fixed write fence must conflict through the shared clients");
|
||||
assert!(matches!(source_err, rustfs_lock::LockError::Timeout { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_snapshot_version_matching_normalizes_null_and_uuid_forms() {
|
||||
let nil = Uuid::nil();
|
||||
@@ -4433,6 +5037,159 @@ mod tests {
|
||||
assert_eq!(lookup_opts.version_id.as_deref(), Some("vid-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_delete_lookup_includes_decommission_source_and_skips_rebalance_source() {
|
||||
let lookup_opts = delete_pool_lookup_opts(&ObjectOptions::default(), true);
|
||||
|
||||
assert!(lookup_opts.no_lock);
|
||||
assert!(!lookup_opts.skip_decommissioned);
|
||||
assert!(lookup_opts.skip_rebalancing);
|
||||
|
||||
let explicit_version = delete_pool_lookup_opts(
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert!(!explicit_version.skip_decommissioned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_fans_out_for_unversioned_and_explicit_version_mutations() {
|
||||
assert!(should_delete_from_all_pools(&ObjectOptions::default(), 1));
|
||||
assert!(should_delete_from_all_pools(
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
2,
|
||||
));
|
||||
assert!(!should_delete_from_all_pools(
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
1,
|
||||
));
|
||||
assert!(!should_delete_from_all_pools(&ObjectOptions::default(), 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delete_identifies_only_latest_versioned_markers() {
|
||||
let versioned = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||
s3s::dto::VersioningConfiguration {
|
||||
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let latest = ObjectToDelete {
|
||||
object_name: "latest".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(batch_delete_creates_latest_marker(&latest, &versioned));
|
||||
assert!(!batch_delete_targets_pool(true, Some(1), 0));
|
||||
assert!(batch_delete_targets_pool(true, Some(1), 1));
|
||||
assert!(!batch_delete_targets_pool(true, Some(1), 2));
|
||||
|
||||
let explicit = ObjectToDelete {
|
||||
object_name: "explicit".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!batch_delete_creates_latest_marker(&explicit, &versioned));
|
||||
assert!(batch_delete_targets_pool(false, Some(1), 0));
|
||||
|
||||
let unversioned = DeleteReplicationConfigSnapshot::default();
|
||||
assert!(!batch_delete_creates_latest_marker(&latest, &unversioned));
|
||||
assert!(batch_delete_targets_pool(false, None, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delete_pool_failures_override_success_in_any_pool_order() {
|
||||
let success = DeletedObject {
|
||||
object_name: "object".to_string(),
|
||||
found: true,
|
||||
..Default::default()
|
||||
};
|
||||
let source_errors = [
|
||||
StorageError::ErasureWriteQuorum,
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "delete_objects_commit",
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
},
|
||||
];
|
||||
|
||||
for source_error in source_errors {
|
||||
for source_first in [true, false] {
|
||||
let failed = (DeletedObject::default(), Some(source_error.clone()));
|
||||
let succeeded = (success.clone(), None);
|
||||
let pool_results = if source_first {
|
||||
vec![failed, succeeded]
|
||||
} else {
|
||||
vec![succeeded, failed]
|
||||
};
|
||||
|
||||
let (_, error, attempted) =
|
||||
resolve_batch_delete_pool_results(None, pool_results.iter().map(|(deleted, error)| (deleted, error)));
|
||||
|
||||
assert!(attempted);
|
||||
assert_eq!(error, Some(source_error.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delete_ignores_missing_pool_only_after_another_pool_succeeds() {
|
||||
let success = DeletedObject {
|
||||
object_name: "object".to_string(),
|
||||
found: true,
|
||||
..Default::default()
|
||||
};
|
||||
let missing_errors = [
|
||||
StorageError::ObjectNotFound("bucket".to_string(), "object".to_string()),
|
||||
StorageError::VersionNotFound("bucket".to_string(), "object".to_string(), "version".to_string()),
|
||||
];
|
||||
|
||||
for missing_error in missing_errors {
|
||||
let missing = (DeletedObject::default(), Some(missing_error.clone()));
|
||||
for missing_first in [true, false] {
|
||||
let succeeded = (success.clone(), None);
|
||||
let pool_results = if missing_first {
|
||||
vec![missing.clone(), succeeded]
|
||||
} else {
|
||||
vec![succeeded, missing.clone()]
|
||||
};
|
||||
let (deleted, error, attempted) =
|
||||
resolve_batch_delete_pool_results(None, pool_results.iter().map(|(deleted, error)| (deleted, error)));
|
||||
|
||||
assert!(attempted);
|
||||
let deleted = deleted.expect("successful pool result should be retained");
|
||||
assert!(deleted.found);
|
||||
assert_eq!(deleted.object_name, success.object_name.as_str());
|
||||
assert!(error.is_none());
|
||||
}
|
||||
|
||||
let missing_only = [missing];
|
||||
let (_, error, attempted) =
|
||||
resolve_batch_delete_pool_results(None, missing_only.iter().map(|(deleted, error)| (deleted, error)));
|
||||
assert!(attempted);
|
||||
assert_eq!(error, Some(missing_error));
|
||||
}
|
||||
|
||||
let silent_missing = [(DeletedObject::default(), None)];
|
||||
let (_, error, attempted) =
|
||||
resolve_batch_delete_pool_results(None, silent_missing.iter().map(|(deleted, error)| (deleted, error)));
|
||||
assert!(attempted);
|
||||
assert!(error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_pool_lookup_opts_keeps_no_lock_for_tiered_moves() {
|
||||
let lookup_opts = data_movement_pool_lookup_opts(
|
||||
|
||||
@@ -1640,6 +1640,60 @@ mod tests {
|
||||
assert_eq!(payload["items"].as_array().expect("items should be an array").len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_query_request_reports_displaced_terminal_detail() {
|
||||
let heal_manager = Arc::new(HealManager::new(
|
||||
Arc::new(MockStorage),
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..HealConfig::default()
|
||||
}),
|
||||
));
|
||||
let mut displaced = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "displaced-channel".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
displaced.id = "displaced-channel-task".to_string();
|
||||
let displaced_id = displaced.id.clone();
|
||||
heal_manager
|
||||
.submit_heal_request(displaced)
|
||||
.await
|
||||
.expect("initial channel task should queue");
|
||||
heal_manager
|
||||
.submit_heal_request(HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "successor-channel".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::High,
|
||||
))
|
||||
.await
|
||||
.expect("successor channel task should displace the initial task");
|
||||
|
||||
let processor = HealChannelProcessor::new(heal_manager);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
processor
|
||||
.process_query_request("displaced-channel".to_string(), displaced_id, None, tx)
|
||||
.await
|
||||
.expect("displaced query should process");
|
||||
let response = rx
|
||||
.await
|
||||
.expect("query response should be returned")
|
||||
.expect("displaced query should remain successful");
|
||||
let payload: serde_json::Value = serde_json::from_slice(response.data.as_deref().expect("status payload should exist"))
|
||||
.expect("status payload should be json");
|
||||
assert_eq!(payload["summary"], "stopped");
|
||||
assert!(
|
||||
response
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("reason=displaced"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_process_query_request_reports_running_for_queued_task() {
|
||||
let heal_manager = create_test_heal_manager();
|
||||
|
||||
+105
-10
@@ -40,6 +40,7 @@ use tracing::{debug, error, info, warn};
|
||||
use super::{DiskError, Endpoint, HealDiskExt as _, local_disk_map_read};
|
||||
|
||||
const KEEP_HEAL_TASK_STATUS_DURATION: Duration = Duration::from_secs(10 * 60);
|
||||
const DISPLACED_HEAL_REASON: &str = "reason=displaced; retry_hint=submit_again";
|
||||
const LOG_COMPONENT_HEAL: &str = "heal";
|
||||
const LOG_SUBSYSTEM_DISK_SCANNER: &str = "disk_scanner";
|
||||
const LOG_SUBSYSTEM_MANAGER: &str = "manager";
|
||||
@@ -120,26 +121,30 @@ struct MrfRepairNoticeTarget {
|
||||
version_id: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct HealAdmissionDecision {
|
||||
result: HealAdmissionResult,
|
||||
displaced_task_id: Option<String>,
|
||||
displaced_request: Option<HealRequest>,
|
||||
}
|
||||
|
||||
impl HealAdmissionDecision {
|
||||
const fn new(result: HealAdmissionResult) -> Self {
|
||||
Self {
|
||||
result,
|
||||
displaced_task_id: None,
|
||||
displaced_request: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted_with_displacement(displaced_task_id: String) -> Self {
|
||||
fn accepted_with_displacement(displaced_request: HealRequest) -> Self {
|
||||
Self {
|
||||
result: HealAdmissionResult::Accepted,
|
||||
displaced_task_id: Some(displaced_task_id),
|
||||
displaced_request: Some(displaced_request),
|
||||
}
|
||||
}
|
||||
|
||||
fn displaced_task_id(&self) -> Option<&str> {
|
||||
self.displaced_request.as_ref().map(|request| request.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_mrf_repair_notice_targets(
|
||||
@@ -151,6 +156,55 @@ fn lock_mrf_repair_notice_targets(
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_displaced_terminals(
|
||||
registry: &StdMutex<HashMap<String, Arc<CompletedHealStatus>>>,
|
||||
) -> StdMutexGuard<'_, HashMap<String, Arc<CompletedHealStatus>>> {
|
||||
match registry.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_displaced_terminal(
|
||||
registry: &StdMutex<HashMap<String, Arc<CompletedHealStatus>>>,
|
||||
request: &HealRequest,
|
||||
) -> Arc<CompletedHealStatus> {
|
||||
let terminal = Arc::new(CompletedHealStatus {
|
||||
heal_type: request.heal_type.clone(),
|
||||
status: HealTaskStatus::Failed {
|
||||
error: format!("heal task displaced by a higher-priority request ({DISPLACED_HEAL_REASON})"),
|
||||
},
|
||||
result_items_truncated: false,
|
||||
completed_at: SystemTime::now(),
|
||||
seqed_items: Vec::new(),
|
||||
next_seq: 0,
|
||||
min_seq: 0,
|
||||
});
|
||||
let mut terminals = lock_displaced_terminals(registry);
|
||||
prune_completed_heal_statuses(&mut terminals);
|
||||
terminals.insert(request.id.clone(), Arc::clone(&terminal));
|
||||
terminal
|
||||
}
|
||||
|
||||
async fn remove_displaced_task_aliases(
|
||||
aliases: &Arc<Mutex<HashMap<String, HealTaskAlias>>>,
|
||||
terminals: &StdMutex<HashMap<String, Arc<CompletedHealStatus>>>,
|
||||
task_id: &str,
|
||||
terminal: &Arc<CompletedHealStatus>,
|
||||
) {
|
||||
let mut aliases = aliases.lock().await;
|
||||
let alias_ids = aliases
|
||||
.iter()
|
||||
.filter_map(|(alias_id, alias)| (alias.task_id == task_id).then_some(alias_id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut displaced_terminals = lock_displaced_terminals(terminals);
|
||||
prune_completed_heal_statuses(&mut displaced_terminals);
|
||||
for alias_id in alias_ids {
|
||||
displaced_terminals.insert(alias_id, Arc::clone(terminal));
|
||||
}
|
||||
aliases.retain(|alias_id, alias| alias_id != task_id && alias.task_id != task_id);
|
||||
}
|
||||
|
||||
async fn remove_task_aliases_for_task(registry: &Arc<Mutex<HashMap<String, HealTaskAlias>>>, task_id: &str) {
|
||||
registry
|
||||
.lock()
|
||||
@@ -618,6 +672,14 @@ pub struct HealManager {
|
||||
/// are shared so the lookup helper can hand a completed entry to a
|
||||
/// caller without cloning the retained result window.
|
||||
completed_heals: Arc<Mutex<HashMap<String, Arc<CompletedHealStatus>>>>,
|
||||
/// Terminals for requests removed by priority displacement. An Accepted
|
||||
/// task ID remains queryable for the same process lifetime and the normal
|
||||
/// ten-minute status TTL; clients should treat `reason=displaced` as a
|
||||
/// terminal result and submit a fresh request. This sidecar is synchronous
|
||||
/// so admission can publish the terminal while the queue transition is
|
||||
/// still under its lock, without awaiting another tokio lock. Queue state
|
||||
/// is process-local, so this guarantee does not extend across restart.
|
||||
displaced_terminals: Arc<StdMutex<HashMap<String, Arc<CompletedHealStatus>>>>,
|
||||
/// Client tokens merged into an existing task id.
|
||||
task_aliases: Arc<Mutex<HashMap<String, HealTaskAlias>>>,
|
||||
/// Heal tasks waiting for a retry backoff to expire.
|
||||
@@ -659,6 +721,7 @@ struct HealQueueContext<'a> {
|
||||
heal_queue: &'a Arc<Mutex<PriorityHealQueue>>,
|
||||
active_heals: &'a Arc<Mutex<HashMap<String, Arc<HealTask>>>>,
|
||||
completed_heals: &'a Arc<Mutex<HashMap<String, Arc<CompletedHealStatus>>>>,
|
||||
displaced_terminals: &'a Arc<StdMutex<HashMap<String, Arc<CompletedHealStatus>>>>,
|
||||
task_aliases: &'a Arc<Mutex<HashMap<String, HealTaskAlias>>>,
|
||||
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
|
||||
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
|
||||
@@ -874,7 +937,7 @@ impl HealManager {
|
||||
result = "accepted_by_displacement",
|
||||
"Heal queue request accepted by displacement"
|
||||
});
|
||||
return HealAdmissionDecision::accepted_with_displacement(displaced.id);
|
||||
return HealAdmissionDecision::accepted_with_displacement(displaced);
|
||||
}
|
||||
|
||||
demote_to_debug_when!(per_object_request, warn, target: "rustfs::heal::manager", {
|
||||
@@ -1105,6 +1168,7 @@ impl HealManager {
|
||||
active_heals: Arc::new(Mutex::new(HashMap::new())),
|
||||
heal_queue: Arc::new(Mutex::new(PriorityHealQueue::new())),
|
||||
completed_heals: Arc::new(Mutex::new(HashMap::new())),
|
||||
displaced_terminals: Arc::new(StdMutex::new(HashMap::new())),
|
||||
task_aliases: Arc::new(Mutex::new(HashMap::new())),
|
||||
retrying_heals: Arc::new(Mutex::new(HashMap::new())),
|
||||
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
|
||||
@@ -1209,6 +1273,10 @@ impl HealManager {
|
||||
active_heals.clear();
|
||||
publish_active_heal_count(&active_heals);
|
||||
self.completed_heals.lock().await.clear();
|
||||
// Do not let the synchronous guard live across the following async lock.
|
||||
{
|
||||
lock_displaced_terminals(&self.displaced_terminals).clear();
|
||||
}
|
||||
self.task_aliases.lock().await.clear();
|
||||
self.retrying_heals.lock().await.clear();
|
||||
lock_mrf_repair_notice_targets(&self.mrf_repair_notice_targets).clear();
|
||||
@@ -1459,7 +1527,11 @@ impl HealManager {
|
||||
task_id = queued_id.to_owned();
|
||||
}
|
||||
let should_notify = matches!(admission, HealAdmissionResult::Accepted) && config.event_driven_scheduler_enable;
|
||||
let displaced_task_id = admission_decision.displaced_task_id;
|
||||
let displaced_task_id = admission_decision.displaced_task_id().map(ToOwned::to_owned);
|
||||
let displaced_terminal = admission_decision
|
||||
.displaced_request
|
||||
.as_ref()
|
||||
.map(|request| record_displaced_terminal(&self.displaced_terminals, request));
|
||||
if matches!(admission, HealAdmissionResult::Accepted | HealAdmissionResult::Merged)
|
||||
&& let Some(target) = mrf_notice_target
|
||||
{
|
||||
@@ -1473,8 +1545,12 @@ impl HealManager {
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
|
||||
if let Some(displaced_task_id) = displaced_task_id {
|
||||
self.remove_aliases_for_task(&displaced_task_id).await;
|
||||
if let (Some(displaced_task_id), Some(displaced_terminal)) = (displaced_task_id, displaced_terminal) {
|
||||
// The queue has already removed the displaced request, so the
|
||||
// synchronous terminal sidecar was published before aliases and
|
||||
// MRF ownership are cleaned up.
|
||||
remove_displaced_task_aliases(&self.task_aliases, &self.displaced_terminals, &displaced_task_id, &displaced_terminal)
|
||||
.await;
|
||||
}
|
||||
|
||||
if should_notify {
|
||||
@@ -1549,6 +1625,15 @@ impl HealManager {
|
||||
}
|
||||
}
|
||||
|
||||
if terminal_completed.is_none() {
|
||||
let mut displaced_terminals = lock_displaced_terminals(&self.displaced_terminals);
|
||||
prune_completed_heal_statuses(&mut displaced_terminals);
|
||||
terminal_completed = displaced_terminals
|
||||
.get(canonical_task_id)
|
||||
.filter(|terminal| matches_path(&terminal.heal_type))
|
||||
.cloned();
|
||||
}
|
||||
|
||||
match terminal_completed {
|
||||
Some(completed) => TaskStateLookup::Completed(completed),
|
||||
None => TaskStateLookup::NotFound,
|
||||
@@ -1669,9 +1754,19 @@ impl HealManager {
|
||||
|
||||
let mut completed_heals = self.completed_heals.lock().await;
|
||||
prune_completed_heal_statuses(&mut completed_heals);
|
||||
completed_heals
|
||||
if completed_heals
|
||||
.values()
|
||||
.any(|completed| heal_type_matches_path(&completed.heal_type, heal_path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
drop(completed_heals);
|
||||
|
||||
let mut displaced_terminals = lock_displaced_terminals(&self.displaced_terminals);
|
||||
prune_completed_heal_statuses(&mut displaced_terminals);
|
||||
displaced_terminals
|
||||
.values()
|
||||
.any(|terminal| heal_type_matches_path(&terminal.heal_type, heal_path))
|
||||
}
|
||||
|
||||
/// Get task progress
|
||||
|
||||
@@ -21,6 +21,7 @@ impl HealManager {
|
||||
let heal_queue = self.heal_queue.clone();
|
||||
let active_heals = self.active_heals.clone();
|
||||
let task_aliases = self.task_aliases.clone();
|
||||
let displaced_terminals = self.displaced_terminals.clone();
|
||||
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
|
||||
let storage = self.storage.clone();
|
||||
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
|
||||
@@ -481,6 +482,10 @@ impl HealManager {
|
||||
let admission = admission_decision.result;
|
||||
let should_notify =
|
||||
matches!(admission, HealAdmissionResult::Accepted) && config.event_driven_scheduler_enable;
|
||||
let displaced_terminal = admission_decision
|
||||
.displaced_request
|
||||
.as_ref()
|
||||
.map(|request| record_displaced_terminal(&displaced_terminals, request));
|
||||
if matches!(admission, HealAdmissionResult::Accepted)
|
||||
&& let Some(anchor) = recovery_anchor
|
||||
{
|
||||
@@ -491,8 +496,16 @@ impl HealManager {
|
||||
}
|
||||
drop(queue);
|
||||
drop(config);
|
||||
if let Some(displaced_task_id) = admission_decision.displaced_task_id {
|
||||
remove_task_aliases_for_task(&task_aliases, &displaced_task_id).await;
|
||||
if let (Some(displaced_task_id), Some(displaced_terminal)) =
|
||||
(admission_decision.displaced_task_id().map(ToOwned::to_owned), displaced_terminal)
|
||||
{
|
||||
remove_displaced_task_aliases(
|
||||
&task_aliases,
|
||||
&displaced_terminals,
|
||||
&displaced_task_id,
|
||||
&displaced_terminal,
|
||||
)
|
||||
.await;
|
||||
lock_mrf_repair_notice_targets(&mrf_repair_notice_targets).remove(&displaced_task_id);
|
||||
}
|
||||
if matches!(admission, HealAdmissionResult::Accepted) {
|
||||
|
||||
@@ -21,6 +21,7 @@ impl HealManager {
|
||||
let heal_queue = self.heal_queue.clone();
|
||||
let active_heals = self.active_heals.clone();
|
||||
let completed_heals = self.completed_heals.clone();
|
||||
let displaced_terminals = self.displaced_terminals.clone();
|
||||
let task_aliases = self.task_aliases.clone();
|
||||
let retrying_heals = self.retrying_heals.clone();
|
||||
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
|
||||
@@ -53,6 +54,7 @@ impl HealManager {
|
||||
heal_queue: &heal_queue,
|
||||
active_heals: &active_heals,
|
||||
completed_heals: &completed_heals,
|
||||
displaced_terminals: &displaced_terminals,
|
||||
task_aliases: &task_aliases,
|
||||
retrying_heals: &retrying_heals,
|
||||
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
||||
@@ -71,6 +73,7 @@ impl HealManager {
|
||||
heal_queue: &heal_queue,
|
||||
active_heals: &active_heals,
|
||||
completed_heals: &completed_heals,
|
||||
displaced_terminals: &displaced_terminals,
|
||||
task_aliases: &task_aliases,
|
||||
retrying_heals: &retrying_heals,
|
||||
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
||||
@@ -98,6 +101,7 @@ impl HealManager {
|
||||
heal_queue,
|
||||
active_heals,
|
||||
completed_heals,
|
||||
displaced_terminals,
|
||||
task_aliases,
|
||||
retrying_heals,
|
||||
mrf_repair_notice_targets,
|
||||
@@ -183,6 +187,7 @@ impl HealManager {
|
||||
let active_heals_clone = active_heals.clone();
|
||||
let heal_queue_clone = heal_queue.clone();
|
||||
let completed_heals_clone = completed_heals.clone();
|
||||
let displaced_terminals_clone = displaced_terminals.clone();
|
||||
let task_aliases_clone = task_aliases.clone();
|
||||
let retrying_heals_clone = retrying_heals.clone();
|
||||
let mrf_repair_notice_targets_clone = mrf_repair_notice_targets.clone();
|
||||
@@ -363,6 +368,7 @@ impl HealManager {
|
||||
let retry_heal_queue = heal_queue_clone.clone();
|
||||
let retrying_heals_for_spawn = retrying_heals_clone.clone();
|
||||
let retry_task_aliases = task_aliases_clone.clone();
|
||||
let retry_displaced_terminals = displaced_terminals_clone.clone();
|
||||
let retry_mrf_repair_notice_targets = mrf_repair_notice_targets_clone.clone();
|
||||
let retry_completed_heals = completed_heals_clone.clone();
|
||||
let retry_notify = notify_clone.clone();
|
||||
@@ -430,6 +436,14 @@ impl HealManager {
|
||||
let admission = admission_decision.result;
|
||||
let should_notify = matches!(admission, HealAdmissionResult::Accepted)
|
||||
&& retry_config.event_driven_scheduler_enable;
|
||||
// Publish the terminal synchronously while the
|
||||
// queue transition is protected. The subsequent
|
||||
// queue -> retrying handoff retains the lock order
|
||||
// used by operations_snapshot.
|
||||
let displaced_terminal = admission_decision
|
||||
.displaced_request
|
||||
.as_ref()
|
||||
.map(|request| record_displaced_terminal(&retry_displaced_terminals, request));
|
||||
match admission {
|
||||
HealAdmissionResult::Accepted => {
|
||||
// Transfer ownership while holding queue -> retrying,
|
||||
@@ -437,10 +451,18 @@ impl HealManager {
|
||||
#[cfg(test)]
|
||||
pause_retry_ownership_transition(&retry_request_id, true).await;
|
||||
retrying_heals_for_spawn.lock().await.remove(&retry_request_id);
|
||||
let displaced_task_id = admission_decision.displaced_task_id;
|
||||
let displaced_task_id = admission_decision.displaced_task_id().map(ToOwned::to_owned);
|
||||
drop(queue);
|
||||
if let Some(displaced_task_id) = displaced_task_id {
|
||||
remove_task_aliases_for_task(&retry_task_aliases, &displaced_task_id).await;
|
||||
if let (Some(displaced_task_id), Some(displaced_terminal)) =
|
||||
(displaced_task_id, displaced_terminal)
|
||||
{
|
||||
remove_displaced_task_aliases(
|
||||
&retry_task_aliases,
|
||||
&retry_displaced_terminals,
|
||||
&displaced_task_id,
|
||||
&displaced_terminal,
|
||||
)
|
||||
.await;
|
||||
remove_mrf_repair_notice_targets(
|
||||
&retry_mrf_repair_notice_targets,
|
||||
&displaced_task_id,
|
||||
|
||||
@@ -84,6 +84,7 @@ async fn process_manager_queue_once(manager: &HealManager) {
|
||||
heal_queue: &manager.heal_queue,
|
||||
active_heals: &manager.active_heals,
|
||||
completed_heals: &manager.completed_heals,
|
||||
displaced_terminals: &manager.displaced_terminals,
|
||||
task_aliases: &manager.task_aliases,
|
||||
retrying_heals: &manager.retrying_heals,
|
||||
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
|
||||
@@ -2778,7 +2779,10 @@ async fn test_high_priority_request_displaces_lower_priority_when_queue_full() {
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
assert_eq!(manager.get_queue_length().await, 1);
|
||||
assert!(matches!(manager.get_task_status(&low_id).await, Err(Error::TaskNotFound { .. })));
|
||||
assert!(matches!(
|
||||
manager.get_task_status(&low_id).await,
|
||||
Ok(HealTaskStatus::Failed { error }) if error.contains("reason=displaced")
|
||||
));
|
||||
assert_eq!(
|
||||
manager
|
||||
.get_task_status(&high_id)
|
||||
@@ -2788,6 +2792,263 @@ async fn test_high_priority_request_displaces_lower_priority_when_queue_full() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn displaced_task_remains_queryable() {
|
||||
let manager = HealManager::new(
|
||||
Arc::new(MockStorage),
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..HealConfig::default()
|
||||
}),
|
||||
);
|
||||
let mut displaced = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "displaced-bucket".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
displaced.id = "displaced-task".to_string();
|
||||
let displaced_id = displaced.id.clone();
|
||||
manager
|
||||
.submit_heal_request(displaced)
|
||||
.await
|
||||
.expect("displaced request should queue");
|
||||
|
||||
let successor = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "successor-bucket".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::High,
|
||||
);
|
||||
manager
|
||||
.submit_heal_request(successor)
|
||||
.await
|
||||
.expect("successor should displace low work");
|
||||
|
||||
let report = manager
|
||||
.get_task_report(&displaced_id)
|
||||
.await
|
||||
.expect("displaced report should remain queryable");
|
||||
assert!(matches!(report.status, HealTaskStatus::Failed { ref error } if error.contains("reason=displaced")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn displaced_archive_failure_keeps_queryable_terminal() {
|
||||
let manager = HealManager::new(Arc::new(MockStorage), None);
|
||||
let mut request = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "archive-failure".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
request.id = "archive-failure-task".to_string();
|
||||
let request_id = request.id.clone();
|
||||
// The synchronous sidecar is the authoritative fallback when the normal
|
||||
// completed-task archive has no entry (the failure window that must not
|
||||
// turn an Accepted ID into NotFound).
|
||||
record_displaced_terminal(&manager.displaced_terminals, &request);
|
||||
assert!(manager.completed_heals.lock().await.is_empty());
|
||||
assert!(matches!(
|
||||
manager.get_task_status(&request_id).await,
|
||||
Ok(HealTaskStatus::Failed { error }) if error.contains("reason=displaced")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_retry_displacement_keeps_evicted_task_queryable() {
|
||||
let manager = Arc::new(HealManager::new(
|
||||
Arc::new(MockStorage),
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
event_driven_scheduler_enable: false,
|
||||
..HealConfig::default()
|
||||
}),
|
||||
));
|
||||
let mut retry_request = HealRequest::object("retry-transition".to_string(), "object".to_string(), None);
|
||||
retry_request.priority = HealPriority::High;
|
||||
let retry_id = retry_request.id.clone();
|
||||
manager
|
||||
.submit_heal_request(retry_request)
|
||||
.await
|
||||
.expect("retry request should queue");
|
||||
|
||||
// Process exactly one queue cycle so the retry task is spawned without a
|
||||
// background scheduler consuming the filler request before the retry wakes.
|
||||
process_manager_queue_once(&manager).await;
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if manager.retrying_heals.lock().await.contains_key(&retry_id) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("retry request should enter backoff");
|
||||
|
||||
let filler = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "retry-displaced-filler".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
let filler_id = filler.id.clone();
|
||||
manager
|
||||
.submit_heal_request(filler)
|
||||
.await
|
||||
.expect("filler request should occupy the queue");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(
|
||||
manager.get_task_status(&filler_id).await,
|
||||
Ok(HealTaskStatus::Failed { ref error }) if error.contains("reason=displaced")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("retry admission should displace the filler request");
|
||||
assert_eq!(manager.get_queue_length().await, 1);
|
||||
assert_eq!(
|
||||
manager.get_task_status(&retry_id).await.expect("retry should be queued"),
|
||||
HealTaskStatus::Pending
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_displacers_produce_one_terminal_generation() {
|
||||
let manager = Arc::new(HealManager::new(
|
||||
Arc::new(MockStorage),
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..HealConfig::default()
|
||||
}),
|
||||
));
|
||||
let mut displaced = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "concurrent-displaced".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
displaced.id = "concurrent-displaced-task".to_string();
|
||||
let displaced_id = displaced.id.clone();
|
||||
manager
|
||||
.submit_heal_request(displaced)
|
||||
.await
|
||||
.expect("initial request should queue");
|
||||
|
||||
let first = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "concurrent-successor-a".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::High,
|
||||
);
|
||||
let second = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "concurrent-successor-b".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::High,
|
||||
);
|
||||
let (first_result, second_result) = tokio::join!(manager.submit_heal_request(first), manager.submit_heal_request(second));
|
||||
let accepted = [&first_result, &second_result]
|
||||
.into_iter()
|
||||
.filter(|result| matches!(result, Ok(HealAdmissionResult::Accepted)))
|
||||
.count();
|
||||
assert_eq!(accepted, 1, "exactly one concurrent displacer should win the full queue");
|
||||
assert!(
|
||||
first_result.is_ok() && second_result.is_ok(),
|
||||
"the losing request should receive a typed Full result"
|
||||
);
|
||||
let terminals = lock_displaced_terminals(&manager.displaced_terminals);
|
||||
assert_eq!(terminals.len(), 1);
|
||||
assert!(terminals.contains_key(&displaced_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successor_chain_is_bounded_and_authorized() {
|
||||
let manager = HealManager::new(
|
||||
Arc::new(MockStorage),
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..HealConfig::default()
|
||||
}),
|
||||
);
|
||||
let mut original = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "authorized-original".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
original.id = "authorized-original-task".to_string();
|
||||
let original_id = original.id.clone();
|
||||
manager.submit_heal_request(original).await.expect("original should queue");
|
||||
let mut duplicate = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "authorized-original".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
duplicate.id = "authorized-duplicate-task".to_string();
|
||||
let duplicate_id = duplicate.id.clone();
|
||||
manager
|
||||
.submit_heal_request(duplicate)
|
||||
.await
|
||||
.expect("same-target duplicate should merge");
|
||||
let successor = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "authorized-successor".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::High,
|
||||
);
|
||||
let successor_id = successor.id.clone();
|
||||
manager.submit_heal_request(successor).await.expect("successor should queue");
|
||||
assert!(manager.task_aliases.lock().await.is_empty());
|
||||
assert!(matches!(manager.get_task_status(&original_id).await, Ok(HealTaskStatus::Failed { .. })));
|
||||
assert!(matches!(manager.get_task_status(&duplicate_id).await, Ok(HealTaskStatus::Failed { .. })));
|
||||
assert_eq!(
|
||||
manager
|
||||
.get_task_status(&successor_id)
|
||||
.await
|
||||
.expect("successor should remain queued"),
|
||||
HealTaskStatus::Pending
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn displaced_terminal_expires_after_bounded_ttl() {
|
||||
let manager = HealManager::new(Arc::new(MockStorage), None);
|
||||
let mut request = HealRequest::new(
|
||||
HealType::Bucket {
|
||||
bucket: "expires".to_string(),
|
||||
},
|
||||
HealOptions::default(),
|
||||
HealPriority::Low,
|
||||
);
|
||||
request.id = "expires-task".to_string();
|
||||
let request_id = request.id.clone();
|
||||
record_displaced_terminal(&manager.displaced_terminals, &request);
|
||||
{
|
||||
let mut terminals = lock_displaced_terminals(&manager.displaced_terminals);
|
||||
let entry =
|
||||
Arc::get_mut(terminals.get_mut(&request_id).expect("terminal should be retained")).expect("test owns terminal entry");
|
||||
entry.completed_at = SystemTime::now() - KEEP_HEAL_TASK_STATUS_DURATION - Duration::from_secs(1);
|
||||
}
|
||||
assert!(matches!(manager.get_task_status(&request_id).await, Err(Error::TaskNotFound { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_displacing_registered_mrf_task_drops_notice_ownership() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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" \
|
||||
|
||||
Reference in New Issue
Block a user