Files
rustfs/crates/ecstore/src/layout/pool_space.rs
T
Zhengchao An 48c8d85f3b fix(ecstore): don't exclude pool when has_space_for is indeterminate (#5497) (#5682)
fix(ecstore): don't exclude pool when has_space_for is indeterminate

When `has_space_for` returns an error (not enough online disks to
reliably determine space), the old code treated this the same as
"definitely no space" via `unwrap_or_default()`, zeroing out the
pool's available capacity.  During pool decommission this creates a
false "Disk full" (500) for S3 PUT requests:

- Pool 0 (decommissioning) is correctly suspended → available = 0
- Pool 1 (active) has some disks whose disk_info call fails under
  heavy migration I/O → has_space_for returns Err → available forced
  to 0
- get_available_pool_idx sees total = 0 → returns None → DiskFull

Fix: distinguish Ok(false) (genuinely full) from Err (indeterminate).
On Err, log a warning and fall through to compute available space from
whatever disks did respond.  The actual write will enforce its own
quorum; a premature zero at the pool-selection layer is a false
rejection.

Closes #5497
2026-08-04 08:50:13 +08:00

345 lines
11 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 std::slice::Iter;
use tracing::warn;
use crate::bucket::utils::is_meta_bucketname;
use crate::disk::DiskInfo;
use crate::error::{Error, Result};
use crate::runtime::global::{DISK_ASSUME_UNKNOWN_SIZE, DISK_FILL_FRACTION, DISK_MIN_INODES};
use crate::runtime::sources as runtime_sources;
#[derive(Debug, Default, Clone)]
pub struct PoolAvailableSpace {
pub index: usize,
pub available: u64, // in bytes
pub max_used_pct: u64, // Used disk percentage of most filled disk, rounded down.
}
#[derive(Debug, Default, Clone)]
pub struct ServerPoolsAvailableSpace(pub(crate) Vec<PoolAvailableSpace>);
impl ServerPoolsAvailableSpace {
pub fn iter(&self) -> Iter<'_, PoolAvailableSpace> {
self.0.iter()
}
// TotalAvailable - total available space
pub fn total_available(&self) -> u64 {
let mut total = 0;
for pool in &self.0 {
total += pool.available;
}
total
}
// FilterMaxUsed will filter out any pools that has used percent bigger than max,
// unless all have that, in which case all are preserved.
pub fn filter_max_used(&mut self, max: u64) {
if self.0.len() <= 1 {
// Nothing to do.
return;
}
let mut ok = false;
for pool in &self.0 {
if pool.available > 0 && pool.max_used_pct < max {
ok = true;
break;
}
}
if !ok {
// All above limit.
// Do not modify
return;
}
// Remove entries that are above.
for pool in self.0.iter_mut() {
if pool.available > 0 && pool.max_used_pct < max {
continue;
}
pool.available = 0
}
}
}
pub async fn has_space_for(dis: &[Option<DiskInfo>], size: i64) -> Result<bool> {
let size = { if size < 0 { DISK_ASSUME_UNKNOWN_SIZE } else { size as u64 * 2 } };
let mut available = 0;
let mut total = 0;
let mut disks_num = 0;
for disk in dis.iter().flatten() {
disks_num += 1;
total += disk.total;
available += disk.total - disk.used;
}
if disks_num < dis.len() / 2 || disks_num == 0 {
return Err(Error::other(format!(
"not enough online disks to calculate the available space,need {}, found {}",
(dis.len() / 2) + 1,
disks_num,
)));
}
let per_disk = size / disks_num as u64;
for disk in dis.iter().flatten() {
if !runtime_sources::setup_is_erasure_sd().await && disk.free_inodes < DISK_MIN_INODES && disk.used_inodes > 0 {
return Ok(false);
}
if disk.free <= per_disk {
return Ok(false);
}
}
if available < size {
return Ok(false);
}
available -= size;
let want = total as f64 * (1.0 - DISK_FILL_FRACTION);
Ok(available > want as u64)
}
pub(crate) async fn build_server_pools_available_space(
bucket: &str,
size: i64,
n_sets: &[usize],
infos: &[Vec<Option<DiskInfo>>],
) -> ServerPoolsAvailableSpace {
let mut server_pools = vec![PoolAvailableSpace::default(); infos.len()];
for (i, zinfo) in infos.iter().enumerate() {
if zinfo.is_empty() {
server_pools[i] = PoolAvailableSpace {
index: i,
..Default::default()
};
continue;
}
if !is_meta_bucketname(bucket) {
match has_space_for(zinfo, size).await {
Ok(false) => {
server_pools[i] = PoolAvailableSpace {
index: i,
..Default::default()
};
continue;
}
Err(err) => {
// Not enough online disks to reliably determine space
// (e.g. transient disk_info failures during decommission
// I/O). Do NOT exclude the pool — compute available
// space from whatever disks responded. The actual write
// will enforce its own quorum; a premature zero here
// causes spurious "Disk full" errors when only the
// decommission source pool is suspended.
warn!(
pool_index = i,
error = %err,
"Pool space check incomplete; falling back to online-disk estimate"
);
}
Ok(true) => {}
}
}
let mut available = 0;
let mut max_used_pct = 0;
for disk in zinfo.iter().flatten() {
if disk.total == 0 {
continue;
}
available += disk.total - disk.used;
let pct_used = disk.used * 100 / disk.total;
if pct_used > max_used_pct {
max_used_pct = pct_used;
}
}
available *= n_sets[i] as u64;
server_pools[i] = PoolAvailableSpace {
index: i,
available,
max_used_pct,
}
}
ServerPoolsAvailableSpace(server_pools)
}
#[cfg(test)]
mod tests {
use super::*;
fn disk_info(total: u64, used: u64, free: u64) -> DiskInfo {
DiskInfo {
total,
used,
free,
free_inodes: 1_024,
..Default::default()
}
}
#[test]
fn pool_available_space_creation_preserves_fields() {
let space = PoolAvailableSpace {
index: 0,
available: 1000,
max_used_pct: 50,
};
assert_eq!(space.index, 0);
assert_eq!(space.available, 1000);
assert_eq!(space.max_used_pct, 50);
}
#[test]
fn server_pools_available_space_filter_keeps_shape_and_updates_availability() {
let mut spaces = ServerPoolsAvailableSpace(vec![
PoolAvailableSpace {
index: 0,
available: 1000,
max_used_pct: 50,
},
PoolAvailableSpace {
index: 1,
available: 2000,
max_used_pct: 80,
},
]);
assert_eq!(spaces.total_available(), 3000);
spaces.filter_max_used(60);
assert_eq!(spaces.0.len(), 2);
assert_eq!(spaces.0[0].index, 0);
assert_eq!(spaces.0[0].available, 1000);
assert_eq!(spaces.0[1].available, 0);
assert_eq!(spaces.total_available(), 1000);
}
#[test]
fn server_pools_available_space_iter_preserves_pool_order() {
let spaces = ServerPoolsAvailableSpace(vec![PoolAvailableSpace {
index: 0,
available: 1000,
max_used_pct: 50,
}]);
let indexes = spaces.iter().map(|space| space.index).collect::<Vec<_>>();
assert_eq!(indexes, vec![0]);
}
#[tokio::test]
async fn build_server_pools_available_space_returns_zero_for_empty_pool_info() {
let spaces = build_server_pools_available_space("bucket-a", 64, &[1], &[Vec::new()]).await;
assert_eq!(spaces.0.len(), 1);
assert_eq!(spaces.0[0].index, 0);
assert_eq!(spaces.0[0].available, 0);
assert_eq!(spaces.0[0].max_used_pct, 0);
}
#[tokio::test]
async fn build_server_pools_available_space_computes_available_capacity_and_max_used_pct() {
let infos = vec![vec![Some(disk_info(1_000, 100, 900)), Some(disk_info(1_000, 200, 800))]];
let spaces = build_server_pools_available_space("bucket-a", 64, &[2], &infos).await;
assert_eq!(spaces.0.len(), 1);
assert_eq!(spaces.0[0].index, 0);
assert_eq!(spaces.0[0].available, 3_400);
assert_eq!(spaces.0[0].max_used_pct, 20);
}
#[tokio::test]
async fn build_server_pools_available_space_skips_capacity_guard_for_meta_bucket() {
let infos = vec![vec![Some(disk_info(10, 9, 1)), Some(disk_info(10, 9, 1))]];
let spaces = build_server_pools_available_space(crate::disk::RUSTFS_META_BUCKET, 1_024, &[1], &infos).await;
assert_eq!(spaces.0.len(), 1);
assert_eq!(spaces.0[0].available, 2);
assert_eq!(spaces.0[0].max_used_pct, 90);
}
/// When some disks are offline (disk_info returned None), `has_space_for`
/// returns an error instead of a definitive "no space". The pool must NOT
/// be excluded — available space should be estimated from the disks that
/// did respond. This covers the decommission scenario where the active
/// pool is the only write target but some of its disks transiently fail
/// their disk_info check under heavy migration I/O.
#[tokio::test]
async fn build_server_pools_available_space_estimates_from_online_disks_when_some_offline() {
// 4-disk set, only2 online — has_space_for returns Err (need >=2).
let infos = vec![vec![
Some(disk_info(1_000, 200, 800)),
None,
None,
Some(disk_info(1_000, 300, 700)),
]];
let spaces = build_server_pools_available_space("bucket-a", 64, &[1], &infos).await;
assert_eq!(spaces.0.len(), 1);
// available = (800 + 700) * n_sets(1) = 1500
assert_eq!(spaces.0[0].available, 1_500);
assert_eq!(spaces.0[0].max_used_pct, 30);
}
/// In a 2-pool decommission scenario, pool 0 is empty (suspended) and
/// pool 1 has some offline disks but still reports available space.
/// `get_available_pool_idx` must find pool 1.
#[tokio::test]
async fn build_server_pools_available_space_active_pool_with_partial_disk_failure_during_decommission() {
// Pool 0: suspended → empty zinfo (handled by get_server_pools_available_space)
// Pool 1: 4-disk set, 2 online (has_space_for returns Err)
let infos = vec![
Vec::new(), // pool 0: suspended, empty
vec![
Some(disk_info(2_000, 500, 1_500)),
None,
None,
Some(disk_info(2_000, 600, 1_400)),
],
];
let spaces = build_server_pools_available_space("bucket-a", 64, &[1, 1], &infos).await;
assert_eq!(spaces.0.len(), 2);
// Pool 0: empty zinfo → available=0
assert_eq!(spaces.0[0].available, 0);
// Pool 1: (1500 + 1400) * 1 = 2900
assert_eq!(spaces.0[1].available, 2_900);
assert_eq!(spaces.total_available(), 2_900);
}
}