mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-18 02:33:15 +00:00
f17ea7f146
* fix(heal): gate auto replacement formatting Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): require replacement target outcomes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bind resumes to replacement targets Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence healing marker ownership Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover replacement target completion Co-Authored-By: heihutu <heihutu@gmail.com> * docs(heal): clarify replacement recovery status Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): canonicalize replacement target checks Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): satisfy marker test module lint Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): scope automatic replacement format Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): require a mounted replacement target Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): avoid cloned ref slice in test Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): revalidate replacement before scanning Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reset stale resume checkpoints Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): release scanner disk map before probing Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): persist replacement intent before format Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fail closed on mountinfo read errors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence replacement target identity Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): order replacement completion cleanup Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): atomically seal replacement completion Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): census replacement target shards Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence replacement recovery ownership Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): preserve replacement recovery anchors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): satisfy replacement recovery lint gates Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): bind replacement identity to mount lease Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover durable replacement recovery states Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): validate persisted resume task identifiers Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): avoid blocking replacement marker CAS Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): report failed marker rollback Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): pin replacement resume schema compatibility Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): preserve durable recovery anchors Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): preserve public disk path semantics Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): use canonical replacement task ids Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover automatic replacement in 3x4 cluster Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): verify replacement target commits Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): persist replacement completion proof Co-Authored-By: heihutu <heihutu@gmail.com> * feat(heal): expose durable replacement status Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bound durable replacement discovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): remove replacement readiness bypass Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry terminal replacement cleanup Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): isolate replacement intents from legacy resume Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): migrate legacy replacement intents at startup Co-Authored-By: heihutu <heihutu@gmail.com> * style(heal): apply strict clippy fix Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): prioritize active replacement recovery state Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): bind readiness to the admitted mount lease Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): atomically publish replacement intents Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): isolate replacement recovery directory Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): tolerate an empty recovery directory Co-Authored-By: heihutu <heihutu@gmail.com> * style(heal): remove redundant disk bytes conversion Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reconcile proof-first replacement recovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): fence torn intent recovery Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover replacement migration conflicts Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): fence replacement lease mount identity Co-Authored-By: heihutu <heihutu@gmail.com> * test(heal): cover missing replacement path admission Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): reject conflicting legacy completion proof Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): fall back to proc mount identity Co-Authored-By: heihutu <heihutu@gmail.com> * feat(admin): expose replacement recovery status Surface the local durable replacement recovery snapshot in the background heal status response so operators can tell whether replacement cleanup is definitive or still pending. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep replacement status compatible Keep the existing background heal status response wire-compatible while retaining the Linux mount lease cleanup needed for the replacement recovery branch. Co-Authored-By: heihutu <heihutu@gmail.com> * style(ecstore): match linux mount lease formatting Keep Linux rustfmt output stable for the replacement mount lease comparison. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): qualify mount lease test constant Use the disk module path for the format config constant in the Linux mount lease regression test. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): keep procfd mount roots directory-safe Use a procfd path with an explicit directory component so Unix directory guards can open the replacement mount lease root with O_NOFOLLOW while preserving handle-relative I/O semantics. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): delete empty leased buckets via dirfd Use the held mount lease fd as the parent for non-force empty bucket deletion on Linux so procfd-rooted paths do not get rejected as BucketNotEmpty. Also make the download-part OpenOptions truncate behavior explicit and keep fsync test recording stable across procfd canonicalization. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): scan leased bucket paths for emptiness Use the local disk I/O root for bucket emptiness probes before non-force bucket deletion and table-bucket metadata checks. This keeps validation on the same mount instance as the subsequent local disk delete path. Co-Authored-By: heihutu <heihutu@gmail.com> * test(ecstore): align lease path test probes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): block unsafe replacement recovery restarts Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): defer blocked replacement candidates Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry transient replacement discovery Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep transient recovery errors retryable Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): block corrupt legacy replacement state Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): classify flat replacement intent corruption Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): keep transient resume loads retryable Classify malformed legacy replacement state as blocking corruption while preserving disk and transient load failures for retry. This avoids permanently blocking replacement recovery on temporary storage errors. Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): avoid latching transient legacy publishes Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): retry blocked legacy migrations Co-Authored-By: heihutu <heihutu@gmail.com> * fix(heal): defer blocked startup recoveries Co-Authored-By: heihutu <heihutu@gmail.com> * fix(ecstore): preserve disk sync limiter across lease roots Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com>
605 lines
22 KiB
Rust
605 lines
22 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use super::*;
|
|
use crate::storage_api_contracts::heal::HealOperations as _;
|
|
use tracing::trace;
|
|
|
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
|
const LOG_SUBSYSTEM_HEAL: &str = "heal";
|
|
const EVENT_HEAL_FORMAT_COMPLETED: &str = "heal_format_completed";
|
|
const EVENT_HEAL_OBJECT_STARTED: &str = "heal_object_started";
|
|
|
|
fn invalid_heal_pool_index(pool_idx: usize, pool_count: usize) -> Error {
|
|
StorageError::InvalidArgument(
|
|
"heal".to_string(),
|
|
"pool".to_string(),
|
|
format!("invalid heal pool index {pool_idx} for {pool_count} pools"),
|
|
)
|
|
}
|
|
|
|
impl ECStore {
|
|
fn get_pools_for_heal_object(&self, opts: &HealOpts) -> Result<Vec<Arc<Sets>>> {
|
|
match opts.pool {
|
|
Some(pool_idx) => Ok(vec![
|
|
self.pools
|
|
.get(pool_idx)
|
|
.cloned()
|
|
.ok_or_else(|| invalid_heal_pool_index(pool_idx, self.pools.len()))?,
|
|
]),
|
|
None => Ok(self.pools.clone()),
|
|
}
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub(super) async fn handle_heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
|
let mut r = HealResultItem {
|
|
heal_item_type: HealItemType::Metadata.to_string(),
|
|
detail: "disk-format".to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
let mut count_no_heal = 0;
|
|
let mut first_error = None;
|
|
for pool in self.pools.iter() {
|
|
let (mut result, err) = pool.heal_format(dry_run).await?;
|
|
if let Some(err) = err {
|
|
match err {
|
|
StorageError::NoHealRequired => {
|
|
count_no_heal += 1;
|
|
}
|
|
err => {
|
|
first_error.get_or_insert(err);
|
|
}
|
|
}
|
|
}
|
|
r.disk_count += result.disk_count;
|
|
r.set_count += result.set_count;
|
|
r.before.drives.append(&mut result.before.drives);
|
|
r.after.drives.append(&mut result.after.drives);
|
|
}
|
|
if let Some(err) = first_error {
|
|
return Ok((r, Some(err)));
|
|
}
|
|
if count_no_heal == self.pools.len() {
|
|
info!(
|
|
event = EVENT_HEAL_FORMAT_COMPLETED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_HEAL,
|
|
dry_run,
|
|
result = "no_heal_required",
|
|
pool_count = self.pools.len(),
|
|
"Heal format completed"
|
|
);
|
|
return Ok((r, Some(StorageError::NoHealRequired)));
|
|
}
|
|
info!(
|
|
event = EVENT_HEAL_FORMAT_COMPLETED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_HEAL,
|
|
dry_run,
|
|
result = "healed_or_inspected",
|
|
disk_count = r.disk_count,
|
|
set_count = r.set_count,
|
|
"Heal format completed"
|
|
);
|
|
Ok((r, None))
|
|
}
|
|
|
|
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
|
|
pub async fn heal_replacement_format(
|
|
&self,
|
|
dry_run: bool,
|
|
pool_index: usize,
|
|
set_index: usize,
|
|
targets: &[String],
|
|
) -> Result<(HealResultItem, Option<Error>)> {
|
|
let pool = self
|
|
.pools
|
|
.get(pool_index)
|
|
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
|
|
let set = pool.disk_set.get(set_index).cloned().ok_or_else(|| {
|
|
StorageError::InvalidArgument(
|
|
"heal".to_string(),
|
|
"set".to_string(),
|
|
format!("invalid heal set index {set_index} for pool {pool_index}"),
|
|
)
|
|
})?;
|
|
|
|
set.heal_replacement_format(dry_run, targets).await
|
|
}
|
|
|
|
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
|
|
pub async fn replacement_targets_have_version(
|
|
&self,
|
|
bucket: &str,
|
|
object: &str,
|
|
version_id: &str,
|
|
pool_index: usize,
|
|
set_index: usize,
|
|
targets: &[String],
|
|
) -> Result<bool> {
|
|
let pool = self
|
|
.pools
|
|
.get(pool_index)
|
|
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
|
|
let set = pool.disk_set.get(set_index).cloned().ok_or_else(|| {
|
|
StorageError::InvalidArgument(
|
|
"heal".to_string(),
|
|
"set".to_string(),
|
|
format!("invalid heal set index {set_index} for pool {pool_index}"),
|
|
)
|
|
})?;
|
|
|
|
set.replacement_targets_have_version(bucket, object, version_id, targets)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub(super) async fn handle_heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
|
let res = self.peer_sys.heal_bucket(bucket, opts).await?;
|
|
|
|
Ok(res)
|
|
}
|
|
|
|
#[instrument(level = "trace", skip(self, opts), fields(bucket = %bucket, object = %object, version_id = %version_id))]
|
|
pub(super) async fn handle_heal_object(
|
|
&self,
|
|
bucket: &str,
|
|
object: &str,
|
|
version_id: &str,
|
|
opts: &HealOpts,
|
|
) -> Result<(HealResultItem, Option<Error>)> {
|
|
trace!(
|
|
event = EVENT_HEAL_OBJECT_STARTED,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_HEAL,
|
|
bucket = %bucket,
|
|
object = %object,
|
|
version_id = %version_id,
|
|
remove = opts.remove,
|
|
scan_mode = ?opts.scan_mode,
|
|
"Heal object started"
|
|
);
|
|
let object = encode_dir_object(object);
|
|
|
|
let pools = self.get_pools_for_heal_object(opts)?;
|
|
|
|
let mut futures = Vec::with_capacity(pools.len());
|
|
for pool in pools.iter() {
|
|
let suspended_complete = {
|
|
let pool_meta = self.pool_meta.read().await;
|
|
pool_meta.is_suspended(pool.pool_idx).then(|| {
|
|
pool_meta
|
|
.pools
|
|
.get(pool.pool_idx)
|
|
.and_then(|status| status.decommission.as_ref())
|
|
.is_some_and(|decommission| decommission.complete)
|
|
})
|
|
};
|
|
if let Some(complete) = suspended_complete {
|
|
if opts.pool.is_some() {
|
|
let _ = pool.get_disks_for_heal_object(&object, opts)?;
|
|
let err = if complete {
|
|
StorageError::InvalidArgument(
|
|
"heal".to_string(),
|
|
"pool".to_string(),
|
|
format!("heal pool {} has completed decommission", pool.pool_idx),
|
|
)
|
|
} else {
|
|
Error::SlowDown
|
|
};
|
|
return Ok((HealResultItem::default(), Some(err)));
|
|
}
|
|
continue;
|
|
}
|
|
futures.push(pool.heal_object(bucket, &object, version_id, opts));
|
|
}
|
|
let results = join_all(futures).await;
|
|
|
|
let mut errs = Vec::with_capacity(self.pools.len());
|
|
let mut ress = Vec::with_capacity(self.pools.len());
|
|
|
|
for res in results.into_iter() {
|
|
match res {
|
|
Ok((result, err)) => {
|
|
let mut result = result;
|
|
result.object = decode_dir_object(&result.object);
|
|
ress.push(result);
|
|
errs.push(err);
|
|
}
|
|
Err(err) => {
|
|
errs.push(Some(err));
|
|
ress.push(HealResultItem::default());
|
|
}
|
|
}
|
|
}
|
|
|
|
for (idx, err) in errs.iter().enumerate() {
|
|
if err.is_none() {
|
|
return Ok((ress.remove(idx), None));
|
|
}
|
|
}
|
|
|
|
// No pool returned a nil error, return the first non 'not found' error
|
|
for (index, err) in errs.iter().enumerate() {
|
|
return match err {
|
|
Some(err) => {
|
|
if is_err_object_not_found(err) || is_err_version_not_found(err) {
|
|
continue;
|
|
}
|
|
Ok((ress.remove(index), Some(err.clone())))
|
|
}
|
|
None => Ok((ress.remove(index), None)),
|
|
};
|
|
}
|
|
|
|
// At this stage, all errors are 'not found'
|
|
if !version_id.is_empty() {
|
|
return Ok((HealResultItem::default(), Some(Error::FileVersionNotFound)));
|
|
}
|
|
|
|
Ok((HealResultItem::default(), Some(Error::FileNotFound)))
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub(super) async fn handle_check_abandoned_parts(&self, bucket: &str, object: &str, opts: &HealOpts) -> Result<()> {
|
|
let _ = (bucket, object, opts);
|
|
// Stale multipart reconciliation is already owned by the lifecycle-driven
|
|
// background cleanup path in `bucket_lifecycle_ops.rs`. There is currently
|
|
// no stable object-heal contract that should fan this request out through
|
|
// pool/set storage layers, so keep the placeholder explicit at the ECStore
|
|
// boundary instead of dispatching into lower layers.
|
|
Err(StorageError::NotImplemented)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::core::pools::{PoolDecommissionInfo, PoolStatus};
|
|
use crate::disk::{DiskOption, format::FormatV3, new_disk};
|
|
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
|
use crate::store::init_format::{load_format_erasure, save_format_file};
|
|
|
|
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
|
|
let format = FormatV3::new(1, 1);
|
|
let endpoint_url = format!("http://127.0.0.1:{}/data", 19000 + pool_idx);
|
|
let mut endpoint = Endpoint::try_from(endpoint_url.as_str()).expect("endpoint should parse");
|
|
endpoint.set_pool_index(pool_idx);
|
|
endpoint.set_set_index(0);
|
|
endpoint.set_disk_index(0);
|
|
|
|
Sets::new(
|
|
vec![None],
|
|
&PoolEndpoints {
|
|
legacy: false,
|
|
set_count: 1,
|
|
drives_per_set: 1,
|
|
endpoints: Endpoints::from(vec![endpoint]),
|
|
cmd_line: String::new(),
|
|
platform: String::new(),
|
|
},
|
|
&format,
|
|
pool_idx,
|
|
0,
|
|
)
|
|
.await
|
|
.expect("minimal pool should build")
|
|
}
|
|
|
|
async fn minimal_heal_store() -> ECStore {
|
|
ECStore {
|
|
id: Uuid::new_v4(),
|
|
disk_map: HashMap::new(),
|
|
pools: vec![minimal_heal_pool(0).await, minimal_heal_pool(1).await],
|
|
peer_sys: S3PeerSys {
|
|
clients: Vec::new(),
|
|
pools_count: 2,
|
|
},
|
|
pool_meta: RwLock::new(PoolMeta::default()),
|
|
rebalance_meta: RwLock::new(None),
|
|
decommission_cancelers: RwLock::new(Vec::new()),
|
|
start_gate: Mutex::new(()),
|
|
pool_meta_save_gate: Mutex::new(()),
|
|
ctx: crate::runtime::instance::bootstrap_ctx(),
|
|
bucket_fence_registry: std::sync::Arc::default(),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heal_object_pool_scope_selects_only_requested_pool() {
|
|
let store = minimal_heal_store().await;
|
|
let pools = store
|
|
.get_pools_for_heal_object(&HealOpts {
|
|
pool: Some(1),
|
|
..Default::default()
|
|
})
|
|
.expect("requested pool should be selected");
|
|
|
|
assert_eq!(pools.len(), 1);
|
|
assert!(Arc::ptr_eq(&pools[0], &store.pools[1]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn heal_object_pool_scope_rejects_invalid_pool() {
|
|
let store = minimal_heal_store().await;
|
|
let err = store
|
|
.get_pools_for_heal_object(&HealOpts {
|
|
pool: Some(2),
|
|
..Default::default()
|
|
})
|
|
.expect_err("out-of-range pool scope must fail closed");
|
|
|
|
assert!(
|
|
matches!(err, StorageError::InvalidArgument(_, ref field, ref reason)
|
|
if field == "pool" && reason.contains("invalid heal pool index 2 for 2 pools")),
|
|
"unexpected invalid pool error: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn scoped_heal_object_defers_when_requested_pool_is_suspended() {
|
|
let mut store = minimal_heal_store().await;
|
|
store.pool_meta = RwLock::new(PoolMeta {
|
|
pools: vec![
|
|
PoolStatus {
|
|
id: 0,
|
|
cmd_line: "pool-0".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: None,
|
|
},
|
|
PoolStatus {
|
|
id: 1,
|
|
cmd_line: "pool-1".to_string(),
|
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
|
decommission: Some(PoolDecommissionInfo {
|
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
|
..Default::default()
|
|
}),
|
|
},
|
|
],
|
|
..Default::default()
|
|
});
|
|
|
|
let (_, err) = store
|
|
.handle_heal_object(
|
|
"bucket",
|
|
"object",
|
|
"",
|
|
&HealOpts {
|
|
pool: Some(1),
|
|
set: Some(0),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("suspended pool should return a deferred heal result");
|
|
|
|
assert!(matches!(err, Some(StorageError::SlowDown)));
|
|
|
|
let (_, err) = store
|
|
.handle_heal_object(
|
|
"bucket",
|
|
"object",
|
|
"",
|
|
&HealOpts {
|
|
set: Some(1),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("unscoped heal should return the active pool result");
|
|
|
|
assert!(matches!(err, Some(StorageError::InvalidArgument(_, ref field, _)) if field == "set"));
|
|
|
|
let err = store
|
|
.handle_heal_object(
|
|
"bucket",
|
|
"object",
|
|
"",
|
|
&HealOpts {
|
|
pool: Some(1),
|
|
set: Some(1),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("invalid set scope should fail before suspended pool deferral");
|
|
|
|
assert!(matches!(err, StorageError::InvalidArgument(_, ref field, _) if field == "set"));
|
|
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
let decommission = pool_meta.pools[1]
|
|
.decommission
|
|
.as_mut()
|
|
.expect("test pool should have decommission state");
|
|
decommission.complete = true;
|
|
}
|
|
let (_, err) = store
|
|
.handle_heal_object(
|
|
"bucket",
|
|
"object",
|
|
"",
|
|
&HealOpts {
|
|
pool: Some(1),
|
|
set: Some(0),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("completed pool should return a terminal heal result");
|
|
|
|
assert!(matches!(
|
|
err,
|
|
Some(StorageError::InvalidArgument(_, ref field, ref reason))
|
|
if field == "pool" && reason.contains("completed decommission")
|
|
));
|
|
|
|
for canceled in [false, true] {
|
|
{
|
|
let mut pool_meta = store.pool_meta.write().await;
|
|
let decommission = pool_meta.pools[1]
|
|
.decommission
|
|
.as_mut()
|
|
.expect("test pool should have decommission state");
|
|
decommission.complete = false;
|
|
decommission.failed = !canceled;
|
|
decommission.canceled = canceled;
|
|
}
|
|
let (_, err) = store
|
|
.handle_heal_object(
|
|
"bucket",
|
|
"object",
|
|
"",
|
|
&HealOpts {
|
|
pool: Some(1),
|
|
set: Some(0),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("clearable terminal pool should return a deferred heal result");
|
|
|
|
assert!(matches!(err, Some(StorageError::SlowDown)));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn handle_heal_format_continues_after_a_pool_error() {
|
|
let canonical_format = FormatV3::new(1, 3);
|
|
let mut foreign_format = canonical_format.clone();
|
|
foreign_format.id = Uuid::new_v4();
|
|
let mut temp_dirs = Vec::new();
|
|
let mut endpoints = Vec::new();
|
|
let mut disks = Vec::new();
|
|
|
|
for disk_index in 0..3 {
|
|
let temp_dir = tempfile::tempdir().expect("temporary disk root should be created");
|
|
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("temporary path should be UTF-8"))
|
|
.expect("temporary endpoint should parse");
|
|
endpoint.set_pool_index(0);
|
|
endpoint.set_set_index(0);
|
|
endpoint.set_disk_index(disk_index);
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("temporary disk should open");
|
|
let mut disk_format = foreign_format.clone();
|
|
disk_format.erasure.this = foreign_format.erasure.sets[0][disk_index];
|
|
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
|
.await
|
|
.expect("foreign format should be written");
|
|
temp_dirs.push(temp_dir);
|
|
endpoints.push(endpoint);
|
|
disks.push(Some(disk));
|
|
}
|
|
|
|
let pool_endpoints = PoolEndpoints {
|
|
legacy: false,
|
|
set_count: 1,
|
|
drives_per_set: 3,
|
|
endpoints: Endpoints::from(endpoints),
|
|
cmd_line: "foreign-format-majority-test".to_string(),
|
|
platform: "test".to_string(),
|
|
};
|
|
let pool = Sets::new(disks, &pool_endpoints, &canonical_format, 0, 1)
|
|
.await
|
|
.expect("test pool should build around the cached canonical format");
|
|
|
|
let mut recoverable_format = FormatV3::new(1, 3);
|
|
recoverable_format.id = canonical_format.id;
|
|
let mut recoverable_temp_dirs = Vec::new();
|
|
let mut recoverable_endpoints = Vec::new();
|
|
let mut recoverable_disks = Vec::new();
|
|
let mut unformatted_disk = None;
|
|
for disk_index in 0..3 {
|
|
let temp_dir = tempfile::tempdir().expect("temporary disk root should be created");
|
|
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("temporary path should be UTF-8"))
|
|
.expect("temporary endpoint should parse");
|
|
endpoint.set_pool_index(1);
|
|
endpoint.set_set_index(0);
|
|
endpoint.set_disk_index(disk_index);
|
|
let disk = new_disk(
|
|
&endpoint,
|
|
&DiskOption {
|
|
cleanup: false,
|
|
health_check: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect("temporary disk should open");
|
|
if disk_index < 2 {
|
|
let mut disk_format = recoverable_format.clone();
|
|
disk_format.erasure.this = recoverable_format.erasure.sets[0][disk_index];
|
|
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
|
.await
|
|
.expect("recoverable format should be written");
|
|
} else {
|
|
unformatted_disk = Some(disk.clone());
|
|
}
|
|
recoverable_temp_dirs.push(temp_dir);
|
|
recoverable_endpoints.push(endpoint);
|
|
recoverable_disks.push(Some(disk));
|
|
}
|
|
let recoverable_pool_endpoints = PoolEndpoints {
|
|
legacy: false,
|
|
set_count: 1,
|
|
drives_per_set: 3,
|
|
endpoints: Endpoints::from(recoverable_endpoints),
|
|
cmd_line: "recoverable-format-test".to_string(),
|
|
platform: "test".to_string(),
|
|
};
|
|
let recoverable_pool = Sets::new(recoverable_disks, &recoverable_pool_endpoints, &recoverable_format, 1, 1)
|
|
.await
|
|
.expect("recoverable test pool should build");
|
|
|
|
let endpoint_pools = EndpointServerPools::from(vec![pool_endpoints.clone(), recoverable_pool_endpoints.clone()]);
|
|
let store = ECStore {
|
|
id: canonical_format.id,
|
|
disk_map: HashMap::new(),
|
|
pools: vec![pool, recoverable_pool],
|
|
peer_sys: S3PeerSys::new(&endpoint_pools),
|
|
pool_meta: RwLock::new(PoolMeta::default()),
|
|
rebalance_meta: RwLock::new(None),
|
|
decommission_cancelers: RwLock::new(Vec::new()),
|
|
start_gate: Mutex::new(()),
|
|
pool_meta_save_gate: Mutex::new(()),
|
|
ctx: crate::runtime::instance::bootstrap_ctx(),
|
|
bucket_fence_registry: std::sync::Arc::default(),
|
|
};
|
|
|
|
let (result, err) = store
|
|
.handle_heal_format(false)
|
|
.await
|
|
.expect("format heal should return the typed pool error");
|
|
assert!(
|
|
matches!(err, Some(StorageError::CorruptedFormat)),
|
|
"foreign format majority must not be downgraded to a successful heal: {err:?}"
|
|
);
|
|
assert_eq!(result.disk_count, 3, "the recoverable pool should still be inspected");
|
|
let healed = load_format_erasure(&unformatted_disk.expect("the unformatted disk handle should be retained"), true)
|
|
.await
|
|
.expect("the later pool should be healed despite the first pool error");
|
|
assert_eq!(healed.erasure.this, recoverable_format.erasure.sets[0][2]);
|
|
}
|
|
}
|