Compare commits

...

1 Commits

Author SHA1 Message Date
Zhengchao An 10967d0815 fix(log-analyzer): track storage probe failures (#7434)
* fix(log-analyzer): track storage probe failures

* fix(error): merge equivalent api message branches

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 20:32:06 +00:00
4 changed files with 34 additions and 78 deletions
+19 -70
View File
@@ -516,7 +516,6 @@ async fn submit_mrf_heal_request(manager: &HealManager, intent: &MrfIntent) -> c
struct MrfRuntime { struct MrfRuntime {
queue: MrfQueue, queue: MrfQueue,
retained_replay_intents: Vec<MrfIntent>,
config: MrfConsumerConfig, config: MrfConsumerConfig,
new_since_flush: usize, new_since_flush: usize,
/// True while the in-memory pending set has changed since the last /// True while the in-memory pending set has changed since the last
@@ -536,7 +535,7 @@ impl MrfRuntime {
fn snapshot(&self) -> (Vec<u8>, Vec<u8>) { fn snapshot(&self) -> (Vec<u8>, Vec<u8>) {
let mut authoritative = Vec::new(); let mut authoritative = Vec::new();
let mut legacy = Vec::new(); let mut legacy = Vec::new();
for intent in self.retained_replay_intents.iter().chain(self.queue.intents()) { for intent in self.queue.intents() {
let scoped_identity = let scoped_identity =
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some(); !matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
if !encode_intent(intent, &mut authoritative) { if !encode_intent(intent, &mut authoritative) {
@@ -674,11 +673,10 @@ pub async fn replay_journal_once(manager: &Arc<HealManager>) -> usize {
struct ReplayOutcome { struct ReplayOutcome {
replayed: usize, replayed: usize,
journal_on_disk: bool, journal_on_disk: bool,
retained_replay_intents: Vec<MrfIntent>,
} }
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize, retained_replay_depth: usize) -> bool { fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize) -> bool {
rearm_incomplete || pending_depth > 0 || retained_replay_depth > 0 rearm_incomplete || pending_depth > 0
} }
/// Shared replay core: read + decode + re-arm, then drain what fits. The /// Shared replay core: read + decode + re-arm, then drain what fits. The
@@ -700,7 +698,6 @@ async fn replay_into(
return ReplayOutcome { return ReplayOutcome {
replayed: 0, replayed: 0,
journal_on_disk: false, journal_on_disk: false,
retained_replay_intents: Vec::new(),
}; };
} }
}, },
@@ -740,13 +737,10 @@ async fn replay_into(
// Drain the replayed intents immediately; whatever the manager refuses // Drain the replayed intents immediately; whatever the manager refuses
// stays armed in `queue` for the consumer's retry loop. // stays armed in `queue` for the consumer's retry loop.
let mut retained_replay_intents = Vec::new();
if backoff_until.is_none() { if backoff_until.is_none() {
while let Some(mut intent) = queue.pop_front() { while let Some(mut intent) = queue.pop_front() {
match submit_mrf_heal_request(manager, &intent).await { match submit_mrf_heal_request(manager, &intent).await {
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => { Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
retained_replay_intents.push(intent);
}
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => { Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
intent.attempts = intent.attempts.saturating_add(1); intent.attempts = intent.attempts.saturating_add(1);
if intent.attempts < MRF_MAX_ATTEMPTS { if intent.attempts < MRF_MAX_ATTEMPTS {
@@ -775,7 +769,7 @@ async fn replay_into(
} }
} }
} }
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth(), retained_replay_intents.len()) { let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth()) {
true true
} else { } else {
!delete_journals().await !delete_journals().await
@@ -783,7 +777,6 @@ async fn replay_into(
ReplayOutcome { ReplayOutcome {
replayed, replayed,
journal_on_disk, journal_on_disk,
retained_replay_intents,
} }
} }
@@ -793,7 +786,6 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
let config = MrfConsumerConfig::default(); let config = MrfConsumerConfig::default();
let mut runtime = MrfRuntime { let mut runtime = MrfRuntime {
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes), queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
retained_replay_intents: Vec::new(),
config: config.clone(), config: config.clone(),
new_since_flush: 0, new_since_flush: 0,
dirty: false, dirty: false,
@@ -805,7 +797,6 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
// on disk whenever any replayed intent still needs a successor snapshot. // on disk whenever any replayed intent still needs a successor snapshot.
let replay = replay_into(&manager, &mut runtime.queue, &mut runtime.backoff_until).await; let replay = replay_into(&manager, &mut runtime.queue, &mut runtime.backoff_until).await;
runtime.journal_on_disk = replay.journal_on_disk; runtime.journal_on_disk = replay.journal_on_disk;
runtime.retained_replay_intents = replay.retained_replay_intents;
// Anything still pending (e.g. the manager was full and backoff armed) // Anything still pending (e.g. the manager was full and backoff armed)
// must be re-persisted by the next flush before replay can delete the // must be re-persisted by the next flush before replay can delete the
// startup anchor. // startup anchor.
@@ -823,7 +814,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
// provably current AND idle (a dirty or pending state // provably current AND idle (a dirty or pending state
// gets one last persist attempt, matching the shutdown // gets one last persist attempt, matching the shutdown
// retry the unconditional flush used to provide). // retry the unconditional flush used to provide).
if runtime.dirty || runtime.queue.depth() > 0 || !runtime.retained_replay_intents.is_empty() { if runtime.dirty || runtime.queue.depth() > 0 {
runtime.flush().await; runtime.flush().await;
} }
tracing::info!( tracing::info!(
@@ -852,7 +843,6 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
match tick_action( match tick_action(
runtime.dirty, runtime.dirty,
runtime.queue.depth(), runtime.queue.depth(),
runtime.retained_replay_intents.len(),
runtime.journal_on_disk, runtime.journal_on_disk,
) { ) {
TickAction::Flush => { TickAction::Flush => {
@@ -867,8 +857,8 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
runtime.dispatch(manager.as_ref()).await; runtime.dispatch(manager.as_ref()).await;
} }
TickAction::DeleteJournal => { TickAction::DeleteJournal => {
// Only remove a stale journal after every replayed // All replayed intents have either been accepted,
// intent has a durable successor proof. // merged, or replaced by a pending successor snapshot.
if delete_journals().await { if delete_journals().await {
runtime.journal_on_disk = false; runtime.journal_on_disk = false;
gauge!("rustfs_heal_mrf_journal_bytes").set(0.0); gauge!("rustfs_heal_mrf_journal_bytes").set(0.0);
@@ -897,13 +887,11 @@ enum TickAction {
Idle, Idle,
} }
fn tick_action(dirty: bool, depth: usize, retained_replay_depth: usize, journal_on_disk: bool) -> TickAction { fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool) -> TickAction {
if dirty { if dirty {
TickAction::Flush TickAction::Flush
} else if depth > 0 { } else if depth > 0 {
TickAction::Retry TickAction::Retry
} else if retained_replay_depth > 0 {
TickAction::Idle
} else if journal_on_disk { } else if journal_on_disk {
TickAction::DeleteJournal TickAction::DeleteJournal
} else { } else {
@@ -936,73 +924,34 @@ mod tests {
// Dirty dominates: a changed pending set flushes even when idle // Dirty dominates: a changed pending set flushes even when idle
// otherwise. // otherwise.
assert!(matches!(tick_action(true, 0, 0, false), Flush)); assert!(matches!(tick_action(true, 0, false), Flush));
assert!(matches!(tick_action(true, 3, 0, true), Flush)); assert!(matches!(tick_action(true, 3, true), Flush));
// Clean backlog: no rewrite, but keep draining so an expired // Clean backlog: no rewrite, but keep draining so an expired
// admission backoff retries on time. // admission backoff retries on time.
assert!(matches!(tick_action(false, 1, 0, false), Retry)); assert!(matches!(tick_action(false, 1, false), Retry));
assert!(matches!(tick_action(false, 2, 0, true), Retry)); assert!(matches!(tick_action(false, 2, true), Retry));
// Replayed records accepted by the manager are still restart anchors
// until a durable successor proof can tombstone them.
assert!(matches!(tick_action(false, 0, 1, true), Idle));
// Quiescent with a stale journal file on disk: remove it. // Quiescent with a stale journal file on disk: remove it.
assert!(matches!(tick_action(false, 0, 0, true), DeleteJournal)); assert!(matches!(tick_action(false, 0, true), DeleteJournal));
// Fully quiescent: nothing to do. // Fully quiescent: nothing to do.
assert!(matches!(tick_action(false, 0, 0, false), Idle)); assert!(matches!(tick_action(false, 0, false), Idle));
} }
#[test] #[test]
fn replay_cleanup_retains_journal_for_unarmed_or_refused_records() { fn replay_cleanup_retains_journal_for_unarmed_or_refused_records() {
assert!( assert!(
replay_must_retain_journal(true, 0, 0), replay_must_retain_journal(true, 0),
"a rejected replay record still needs its disk anchor" "a rejected replay record still needs its disk anchor"
); );
assert!( assert!(
replay_must_retain_journal(false, 1, 0), replay_must_retain_journal(false, 1),
"a Full admission retry must keep the startup journal until the next snapshot" "a Full admission retry must keep the startup journal until the next snapshot"
); );
assert!( assert!(
replay_must_retain_journal(false, 0, 1), !replay_must_retain_journal(false, 0),
"an accepted replay record still needs a durable successor before cleanup" "only a fully consumed replay snapshot may be deleted"
);
assert!(
!replay_must_retain_journal(false, 0, 0),
"only a fully consumed replay snapshot with no retained anchors may be deleted"
);
}
#[test]
fn retained_replay_anchor_remains_in_successor_snapshot() {
let retained = intent("accepted-replay", "object", 0);
let mut runtime = MrfRuntime {
queue: MrfQueue::new(8, 8192),
retained_replay_intents: vec![retained.clone()],
config: MrfConsumerConfig::default(),
new_since_flush: 0,
dirty: false,
journal_on_disk: true,
backoff_until: None,
};
assert_eq!(
runtime.queue.try_push_typed(intent("new-pending", "object", 0)),
MrfQueuePushResult::Enqueued
);
let (authoritative, legacy) = runtime.snapshot();
let (decoded, truncated) = decode_journal(&authoritative);
let (legacy_decoded, legacy_truncated) = decode_journal(&legacy);
assert_eq!(truncated, 0);
assert_eq!(legacy_truncated, 0);
assert_eq!(decoded.len(), 2);
assert_eq!(legacy_decoded.len(), 2);
assert!(
decoded.iter().any(|intent| intent.bucket == retained.bucket),
"accepted replay anchor must remain crash-replayable"
); );
} }
+7 -4
View File
@@ -68,14 +68,17 @@ pub(super) fn rules() -> Vec<Rule> {
) )
}, },
Rule { Rule {
anchors: strings(["reporting peer disks offline after consecutive storage_info failures"]), anchors: strings(["Storage inventory probe failed; current drive health is unknown"]),
..base( ..base(
"peer-disks-offline", "peer-disks-offline",
P2Degraded, P2Degraded,
"disk", "disk",
"peer 磁盘被整体判定离线", "peer 存储清单探测失败",
contains("reporting peer disks offline after consecutive storage_info failures"), any([
"对某 peer 连续 storage_info 失败,判定其磁盘整体离线。", contains("Storage inventory probe failed; current drive health is unknown"),
contains("reporting peer disks offline after consecutive storage_info failures"),
]),
"某 peer 的 storage_info 探测失败,当前磁盘健康状态未知。",
"检查该 peer 节点存活与 RPC 端口可达。", "检查该 peer 节点存活与 RPC 端口可达。",
) )
}, },
+5 -1
View File
@@ -110,7 +110,7 @@ fn every_rule_has_a_positive_sample() {
("remote-peer-faulty", msg("Remote peer health check failed for node2: marking as faulty")), ("remote-peer-faulty", msg("Remote peer health check failed for node2: marking as faulty")),
( (
"peer-disks-offline", "peer-disks-offline",
msg("reporting peer disks offline after consecutive storage_info failures"), msg("Storage inventory probe failed; current drive health is unknown"),
), ),
("drive-faulty-error", msg("remote drive is faulty")), ("drive-faulty-error", msg("remote drive is faulty")),
( (
@@ -318,6 +318,10 @@ fn smoke_samples_hit_exact_rule_sets() {
&["disk-marked-faulty"], &["disk-marked-faulty"],
); );
exact(&msg("erasure write quorum (required=8, achieved=5)"), &["ec-write-quorum"]); exact(&msg("erasure write quorum (required=8, achieved=5)"), &["ec-write-quorum"]);
exact(
&msg("reporting peer disks offline after consecutive storage_info failures"),
&["peer-disks-offline"],
);
exact( exact(
&Sample { &Sample {
message: "Metacache listing quorum failed", message: "Metacache listing quorum failed",
+3 -3
View File
@@ -496,9 +496,9 @@ impl From<StorageError> for ApiError {
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) { let message = if matches!(&err, StorageError::QuotaExceeded { .. }) {
err.to_string() err.to_string()
} else if matches!(&err, StorageError::MaxVersionsExceeded) { } else if matches!(&err, StorageError::MaxVersionsExceeded)
ApiError::error_code_to_message(&code) || (code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)))
} else if code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)) { {
ApiError::error_code_to_message(&code) ApiError::error_code_to_message(&code)
} else if code == S3ErrorCode::InternalError { } else if code == S3ErrorCode::InternalError {
err.to_string() err.to_string()