mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 16:28:15 +00:00
fix(heal): classify heal_object errors by type instead of "not found" substring (backlog#856) (#4295)
fix(heal): classify heal_object errors by type, not "not found" substring (backlog#856)
The heal page loop classified heal_object errors with a substring test
(`message.contains("not found")`). `StorageError::DiskNotFound` renders as
"disk not found", so an offline drive during a deep-scan heal matched the
"object absent" branch: the object was returned as `Ok(false)`, recorded into
the checkpoint's processed set, counted as a success, and permanently skipped
on resume — the object silently never got healed.
Replace the substring test with a typed classifier over the wrapped
`StorageError`:
- genuine object/version absence (FileNotFound / FileVersionNotFound /
ObjectNotFound / VersionNotFound) -> Absent (treated as handled);
- transient infrastructure conditions (quorum errors, DiskNotFound,
VolumeNotFound, SlowDown, OperationCanceled) -> TransientSkip, so the object
is retried on a later pass instead of recorded as processed;
- everything else -> Failed (recorded as failed).
Deliberately does NOT use `StorageError::is_not_found()`, which lumps
DiskNotFound/VolumeNotFound with object absence — the exact conflation that
caused the bug. Applied to both the deep-scan and normal-scan heal_object call
sites. Adds regression tests for the classifier.
Refs backlog#799 (B7).
This commit is contained in:
@@ -29,7 +29,19 @@ use std::sync::{
|
||||
use tokio::sync::{RwLock, Semaphore};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::DiskStore;
|
||||
use super::{DiskStore, EcstoreError};
|
||||
|
||||
/// Outcome of classifying an error returned by [`HealStorageAPI::heal_object`].
|
||||
enum HealObjectOutcome {
|
||||
/// The object/version is genuinely absent — nothing left to heal.
|
||||
Absent,
|
||||
/// A transient infrastructure condition (offline disk, unmet quorum,
|
||||
/// contended lock). The object is skipped and retried on a later pass
|
||||
/// instead of being recorded as processed.
|
||||
Transient,
|
||||
/// A real heal failure that should be recorded as failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
const LOG_COMPONENT_HEAL: &str = "heal";
|
||||
const LOG_SUBSYSTEM_ERASURE_HEALER: &str = "erasure_healer";
|
||||
@@ -87,8 +99,44 @@ impl ErasureSetHealer {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_object_not_found_message(message: &str) -> bool {
|
||||
message.contains("File not found") || message.contains("not found")
|
||||
/// Classify an error returned by [`HealStorageAPI::heal_object`].
|
||||
///
|
||||
/// Both the inner `Ok((_, Some(err)))` and the outer `Err(err)` produced by
|
||||
/// `heal_object` wrap `Error::Storage(StorageError)`, so match on that.
|
||||
fn classify_heal_object_error(err: &Error) -> HealObjectOutcome {
|
||||
let Error::Storage(se) = err else {
|
||||
return HealObjectOutcome::Failed;
|
||||
};
|
||||
|
||||
// Genuine object/version absence: nothing left to heal, treat as handled.
|
||||
if matches!(
|
||||
se,
|
||||
EcstoreError::FileNotFound
|
||||
| EcstoreError::FileVersionNotFound
|
||||
| EcstoreError::ObjectNotFound(_, _)
|
||||
| EcstoreError::VersionNotFound(_, _, _)
|
||||
) {
|
||||
return HealObjectOutcome::Absent;
|
||||
}
|
||||
|
||||
// Transient infrastructure conditions — skip and retry on a later pass.
|
||||
// NOTE: do NOT use `StorageError::is_not_found()` here: it lumps
|
||||
// `DiskNotFound`/`VolumeNotFound` together with object absence, which is
|
||||
// exactly the conflation that previously let an offline drive be recorded
|
||||
// as "healed/absent" and permanently skipped (backlog#856 / #799 B7).
|
||||
if se.is_quorum_error()
|
||||
|| matches!(
|
||||
se,
|
||||
EcstoreError::DiskNotFound
|
||||
| EcstoreError::VolumeNotFound
|
||||
| EcstoreError::SlowDown
|
||||
| EcstoreError::OperationCanceled
|
||||
)
|
||||
{
|
||||
return HealObjectOutcome::Transient;
|
||||
}
|
||||
|
||||
HealObjectOutcome::Failed
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
@@ -523,22 +571,13 @@ impl ErasureSetHealer {
|
||||
} else if deep_scan {
|
||||
match storage.heal_object(&bucket_name, &object_name, None, &heal_opts).await {
|
||||
Ok((_result, None)) => Ok(true),
|
||||
Ok((_, Some(err))) => {
|
||||
let err_msg = err.to_string();
|
||||
if Self::is_object_not_found_message(&err_msg) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(Error::other(err))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let err_msg = err.to_string();
|
||||
if Self::is_object_not_found_message(&err_msg) {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
Ok((_, Some(err))) | Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent => Ok(false),
|
||||
HealObjectOutcome::Transient => Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
|
||||
))),
|
||||
HealObjectOutcome::Failed => Err(err),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
let object_exists = match storage.object_exists(&bucket_name, &object_name).await {
|
||||
@@ -576,8 +615,13 @@ impl ErasureSetHealer {
|
||||
match storage.heal_object(&bucket_name, &object_name, None, &heal_opts).await {
|
||||
Ok((_result, None)) => Ok(true),
|
||||
Ok((_, Some(err))) if is_missing_object_dir_heal_result(&object_name, &err) => Ok(false),
|
||||
Ok((_, Some(err))) => Err(Error::other(err)),
|
||||
Err(err) => Err(err),
|
||||
Ok((_, Some(err))) | Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent => Ok(false),
|
||||
HealObjectOutcome::Transient => Err(Error::transient_skip(format!(
|
||||
"Skipped heal for {bucket_name}/{object_name} due to transient error: {err}"
|
||||
))),
|
||||
HealObjectOutcome::Failed => Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1052,4 +1096,51 @@ mod tests {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Regression guards for backlog#856 / #799 B7: heal-object error
|
||||
// classification must not conflate an offline drive (or unmet quorum) with
|
||||
// genuine object absence, or transient failures get recorded as "healed" and
|
||||
// permanently skipped.
|
||||
use super::{EcstoreError, Error, HealObjectOutcome};
|
||||
|
||||
fn classify(err: EcstoreError) -> HealObjectOutcome {
|
||||
ErasureSetHealer::classify_heal_object_error(&Error::Storage(err))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_not_found_is_transient_not_absent() {
|
||||
assert!(matches!(classify(EcstoreError::DiskNotFound), HealObjectOutcome::Transient));
|
||||
assert!(matches!(classify(EcstoreError::VolumeNotFound), HealObjectOutcome::Transient));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quorum_errors_are_transient() {
|
||||
assert!(matches!(classify(EcstoreError::ErasureReadQuorum), HealObjectOutcome::Transient));
|
||||
assert!(matches!(
|
||||
classify(EcstoreError::InsufficientReadQuorum(String::new(), String::new())),
|
||||
HealObjectOutcome::Transient
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_object_absence_is_absent() {
|
||||
assert!(matches!(classify(EcstoreError::FileNotFound), HealObjectOutcome::Absent));
|
||||
assert!(matches!(classify(EcstoreError::FileVersionNotFound), HealObjectOutcome::Absent));
|
||||
assert!(matches!(
|
||||
classify(EcstoreError::ObjectNotFound("bucket".into(), "object".into())),
|
||||
HealObjectOutcome::Absent
|
||||
));
|
||||
assert!(matches!(
|
||||
classify(EcstoreError::VersionNotFound("bucket".into(), "object".into(), "vid".into())),
|
||||
HealObjectOutcome::Absent
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_errors_are_failures() {
|
||||
assert!(matches!(
|
||||
ErasureSetHealer::classify_heal_object_error(&Error::Other("boom".into())),
|
||||
HealObjectOutcome::Failed
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user