feat(scanner): add source-aware maintenance controls (#3461)

* feat(scanner): add source-aware maintenance controls

* fix(scanner): preserve source control between cycles

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
This commit is contained in:
Henry Guo
2026-06-15 12:12:05 +08:00
committed by GitHub
parent f68967de7a
commit 46fb4bdc2f
5 changed files with 572 additions and 14 deletions
+269
View File
@@ -762,6 +762,34 @@ pub struct ScannerSourceWorkSnapshot {
pub missed: u64, pub missed: u64,
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScannerMaintenanceSourceSnapshot {
pub source: String,
pub state: String,
pub reason: String,
pub backlog: u64,
pub current_checked: u64,
pub current_queued: u64,
pub current_missed: u64,
pub lifetime_missed: u64,
pub partial_cycles: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScannerMaintenanceControlSnapshot {
pub primary_control: String,
pub sources: Vec<ScannerMaintenanceSourceSnapshot>,
}
impl Default for ScannerMaintenanceControlSnapshot {
fn default() -> Self {
Self {
primary_control: SCANNER_MAINTENANCE_CONTROL_NONE.to_string(),
sources: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScannerSourceCycleSnapshot { pub struct ScannerSourceCycleSnapshot {
pub source: String, pub source: String,
@@ -940,6 +968,8 @@ pub struct ScannerMetricsReport {
#[serde(default)] #[serde(default)]
pub lifecycle_transition: ScannerLifecycleTransitionSnapshot, pub lifecycle_transition: ScannerLifecycleTransitionSnapshot,
#[serde(default)] #[serde(default)]
pub maintenance_control: ScannerMaintenanceControlSnapshot,
#[serde(default)]
pub throttle_idle_mode_enabled: bool, pub throttle_idle_mode_enabled: bool,
#[serde(default)] #[serde(default)]
pub throttle_sleep_factor: f64, pub throttle_sleep_factor: f64,
@@ -1090,6 +1120,164 @@ fn scanner_pacing_pressure(metrics: &ScannerMetricsReport) -> ScannerPacingPress
} }
} }
const SCANNER_MAINTENANCE_CONTROL_NONE: &str = "none";
const SCANNER_MAINTENANCE_CONTROL_BLOCKED_SOURCE: &str = "blocked_source";
const SCANNER_MAINTENANCE_CONTROL_DEFERRED_SOURCE: &str = "deferred_source";
const SCANNER_MAINTENANCE_CONTROL_ACTIVE_SOURCE: &str = "active_source";
const SCANNER_MAINTENANCE_CONTROL_PACING_PRESSURE: &str = "pacing_pressure";
const SCANNER_MAINTENANCE_STATE_IDLE: &str = "idle";
const SCANNER_MAINTENANCE_STATE_ACTIVE: &str = "active";
const SCANNER_MAINTENANCE_STATE_DEFERRED: &str = "deferred";
const SCANNER_MAINTENANCE_STATE_BLOCKED: &str = "blocked";
const SCANNER_MAINTENANCE_REASON_IDLE: &str = "idle";
const SCANNER_MAINTENANCE_REASON_ACTIVE_WORK: &str = "active_work";
const SCANNER_MAINTENANCE_REASON_QUEUED_WORK: &str = "queued_work";
const SCANNER_MAINTENANCE_REASON_PARTIAL_CYCLE: &str = "partial_cycle";
const SCANNER_MAINTENANCE_REASON_MISSED_WORK: &str = "missed_work";
const SCANNER_MAINTENANCE_REASON_TRANSITION_QUEUE_BACKLOG: &str = "transition_queue_backlog";
const SCANNER_MAINTENANCE_REASON_TRANSITION_QUEUE_FULL: &str = "transition_queue_full";
fn scanner_source_work_snapshot(work: &[ScannerSourceWorkSnapshot], source: ScannerWorkSource) -> ScannerSourceWorkSnapshot {
work.iter()
.find(|snapshot| snapshot.source == source.as_str())
.cloned()
.unwrap_or_else(|| ScannerSourceWorkSnapshot {
source: source.as_str().to_string(),
..Default::default()
})
}
fn scanner_source_partial_cycles(metrics: &ScannerMetricsReport, source: ScannerWorkSource) -> u64 {
metrics
.partial_cycles_by_source
.iter()
.find(|snapshot| snapshot.source == source.as_str())
.map(|snapshot| snapshot.cycles)
.unwrap_or_default()
}
fn scanner_source_has_current_work(work: &ScannerSourceWorkSnapshot) -> bool {
work.checked > 0 || work.queued > 0 || work.executed > 0 || work.failed > 0 || work.skipped > 0 || work.missed > 0
}
fn scanner_lifecycle_transition_backlog(metrics: &ScannerMetricsReport) -> u64 {
metrics
.lifecycle_transition
.current_queued
.saturating_add(metrics.lifecycle_transition.current_active)
}
fn scanner_source_maintenance_state(
source: ScannerWorkSource,
current: &ScannerSourceWorkSnapshot,
metrics: &ScannerMetricsReport,
) -> (&'static str, &'static str, u64) {
if current.missed > 0 {
return (SCANNER_MAINTENANCE_STATE_BLOCKED, SCANNER_MAINTENANCE_REASON_MISSED_WORK, current.missed);
}
if source == ScannerWorkSource::Lifecycle {
let transition_backlog = scanner_lifecycle_transition_backlog(metrics);
if metrics.lifecycle_transition.current_queue_capacity > 0
&& metrics.lifecycle_transition.current_queued >= metrics.lifecycle_transition.current_queue_capacity
{
return (
SCANNER_MAINTENANCE_STATE_BLOCKED,
SCANNER_MAINTENANCE_REASON_TRANSITION_QUEUE_FULL,
transition_backlog,
);
}
}
if metrics.last_cycle_partial_source == source.as_str() && metrics.pacing_pressure.last_cycle_budget_limited {
return (SCANNER_MAINTENANCE_STATE_DEFERRED, SCANNER_MAINTENANCE_REASON_PARTIAL_CYCLE, 0);
}
if current.queued > 0 {
return (SCANNER_MAINTENANCE_STATE_ACTIVE, SCANNER_MAINTENANCE_REASON_QUEUED_WORK, current.queued);
}
if source == ScannerWorkSource::Lifecycle {
let transition_backlog = scanner_lifecycle_transition_backlog(metrics);
if transition_backlog > 0 {
return (
SCANNER_MAINTENANCE_STATE_ACTIVE,
SCANNER_MAINTENANCE_REASON_TRANSITION_QUEUE_BACKLOG,
transition_backlog,
);
}
}
if scanner_source_has_current_work(current) {
return (SCANNER_MAINTENANCE_STATE_ACTIVE, SCANNER_MAINTENANCE_REASON_ACTIVE_WORK, 0);
}
(SCANNER_MAINTENANCE_STATE_IDLE, SCANNER_MAINTENANCE_REASON_IDLE, 0)
}
fn scanner_maintenance_source_work(metrics: &ScannerMetricsReport) -> &[ScannerSourceWorkSnapshot] {
if metrics.current_cycle_source_work.is_empty() {
&metrics.last_cycle_source_work
} else {
&metrics.current_cycle_source_work
}
}
fn scanner_maintenance_control(metrics: &ScannerMetricsReport) -> ScannerMaintenanceControlSnapshot {
let mut has_blocked = false;
let mut has_deferred = false;
let mut has_active = false;
let current_source_work = scanner_maintenance_source_work(metrics);
let sources = ScannerWorkSource::all()
.iter()
.map(|source| {
let current = scanner_source_work_snapshot(current_source_work, *source);
let lifetime = scanner_source_work_snapshot(&metrics.source_work, *source);
let partial_cycles = scanner_source_partial_cycles(metrics, *source);
let (state, reason, backlog) = scanner_source_maintenance_state(*source, &current, metrics);
match state {
SCANNER_MAINTENANCE_STATE_BLOCKED => has_blocked = true,
SCANNER_MAINTENANCE_STATE_DEFERRED => has_deferred = true,
SCANNER_MAINTENANCE_STATE_ACTIVE => has_active = true,
_ => {}
}
ScannerMaintenanceSourceSnapshot {
source: source.as_str().to_string(),
state: state.to_string(),
reason: reason.to_string(),
backlog,
current_checked: current.checked,
current_queued: current.queued,
current_missed: current.missed,
lifetime_missed: lifetime.missed,
partial_cycles,
}
})
.collect();
let primary_control = if has_blocked {
SCANNER_MAINTENANCE_CONTROL_BLOCKED_SOURCE
} else if has_deferred {
SCANNER_MAINTENANCE_CONTROL_DEFERRED_SOURCE
} else if has_active {
SCANNER_MAINTENANCE_CONTROL_ACTIVE_SOURCE
} else if metrics.pacing_pressure.primary_pressure != SCANNER_MAINTENANCE_CONTROL_NONE {
SCANNER_MAINTENANCE_CONTROL_PACING_PRESSURE
} else {
SCANNER_MAINTENANCE_CONTROL_NONE
};
ScannerMaintenanceControlSnapshot {
primary_control: primary_control.to_string(),
sources,
}
}
fn duration_millis_saturated(duration: Duration) -> u64 { fn duration_millis_saturated(duration: Duration) -> u64 {
duration.as_millis().min(u64::MAX as u128) as u64 duration.as_millis().min(u64::MAX as u128) as u64
} }
@@ -2163,6 +2351,7 @@ impl Metrics {
} }
m.pacing_pressure = scanner_pacing_pressure(&m); m.pacing_pressure = scanner_pacing_pressure(&m);
m.maintenance_control = scanner_maintenance_control(&m);
m m
} }
@@ -2479,6 +2668,86 @@ mod tests {
assert_eq!(report.lifecycle_transition.failed, 1); assert_eq!(report.lifecycle_transition.failed, 1);
} }
#[tokio::test]
async fn report_derives_scanner_maintenance_control_status() {
let metrics = Metrics::new();
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_checked(ScannerWorkSource::Usage, 3);
metrics.record_scanner_source_missed(ScannerWorkSource::Lifecycle, 2);
metrics.record_scanner_source_queued(ScannerWorkSource::BucketReplication, 1);
metrics.record_scan_cycle_partial_with_source(
Duration::from_secs(5),
ScanCyclePartialReason::Objects,
Some(ScannerWorkSource::Usage),
);
metrics.record_scanner_lifecycle_transition_state(ScannerLifecycleTransitionStateUpdate {
queue_capacity: 4,
queued: 2,
queue_full: 1,
..Default::default()
});
let report = metrics.report().await;
assert_eq!(report.maintenance_control.primary_control, "blocked_source");
let usage = report
.maintenance_control
.sources
.iter()
.find(|source| source.source == ScannerWorkSource::Usage.as_str())
.expect("usage source control should be visible");
assert_eq!(usage.state, "deferred");
assert_eq!(usage.reason, "partial_cycle");
assert_eq!(usage.current_checked, 3);
assert_eq!(usage.partial_cycles, 1);
let lifecycle = report
.maintenance_control
.sources
.iter()
.find(|source| source.source == ScannerWorkSource::Lifecycle.as_str())
.expect("lifecycle source control should be visible");
assert_eq!(lifecycle.state, "blocked");
assert_eq!(lifecycle.reason, "missed_work");
assert_eq!(lifecycle.current_missed, 2);
assert_eq!(lifecycle.backlog, 2);
let bucket_replication = report
.maintenance_control
.sources
.iter()
.find(|source| source.source == ScannerWorkSource::BucketReplication.as_str())
.expect("bucket replication source control should be visible");
assert_eq!(bucket_replication.state, "active");
assert_eq!(bucket_replication.reason, "queued_work");
assert_eq!(bucket_replication.current_queued, 1);
metrics.finish_scan_cycle_work(start);
}
#[tokio::test]
async fn report_uses_last_cycle_source_work_for_maintenance_control_between_cycles() {
let metrics = Metrics::new();
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_missed(ScannerWorkSource::Lifecycle, 2);
metrics.finish_scan_cycle_work(start);
let report = metrics.report().await;
assert!(report.current_cycle_source_work.is_empty());
let lifecycle = report
.maintenance_control
.sources
.iter()
.find(|source| source.source == ScannerWorkSource::Lifecycle.as_str())
.expect("lifecycle source control should be visible");
assert_eq!(report.maintenance_control.primary_control, "blocked_source");
assert_eq!(lifecycle.state, "blocked");
assert_eq!(lifecycle.reason, "missed_work");
assert_eq!(lifecycle.current_missed, 2);
assert_eq!(lifecycle.backlog, 2);
}
#[tokio::test] #[tokio::test]
async fn scanner_metrics_update_source_work() { async fn scanner_metrics_update_source_work() {
let metrics = Metrics::new(); let metrics = Metrics::new();
+59 -1
View File
@@ -19,7 +19,9 @@ use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use rustfs_madmin::metrics::{ use rustfs_madmin::metrics::{
DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics, DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics,
ScannerCheckpointReport as MadminScannerCheckpointReport, ScannerCheckpointReport as MadminScannerCheckpointReport,
ScannerLifecycleTransitionSnapshot as MadminScannerLifecycleTransitionSnapshot, ScannerMetrics as MadminScannerMetrics, ScannerLifecycleTransitionSnapshot as MadminScannerLifecycleTransitionSnapshot,
ScannerMaintenanceControlSnapshot as MadminScannerMaintenanceControlSnapshot,
ScannerMaintenanceSourceSnapshot as MadminScannerMaintenanceSourceSnapshot, ScannerMetrics as MadminScannerMetrics,
ScannerPacingPressureSnapshot as MadminScannerPacingPressureSnapshot, ScannerPacingPressureSnapshot as MadminScannerPacingPressureSnapshot,
ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot, ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot,
TimedAction as MadminTimedAction, TimedAction as MadminTimedAction,
@@ -177,6 +179,25 @@ fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsRepo
completed: metrics.lifecycle_transition.completed, completed: metrics.lifecycle_transition.completed,
failed: metrics.lifecycle_transition.failed, failed: metrics.lifecycle_transition.failed,
}, },
maintenance_control: MadminScannerMaintenanceControlSnapshot {
primary_control: metrics.maintenance_control.primary_control,
sources: metrics
.maintenance_control
.sources
.into_iter()
.map(|source| MadminScannerMaintenanceSourceSnapshot {
source: source.source,
state: source.state,
reason: source.reason,
backlog: source.backlog,
current_checked: source.current_checked,
current_queued: source.current_queued,
current_missed: source.current_missed,
lifetime_missed: source.lifetime_missed,
partial_cycles: source.partial_cycles,
})
.collect(),
},
partial_cycles_by_source: metrics partial_cycles_by_source: metrics
.partial_cycles_by_source .partial_cycles_by_source
.into_iter() .into_iter()
@@ -580,6 +601,43 @@ mod test {
assert_eq!(scanner.last_cycle_lifecycle_transition_actions, 7); assert_eq!(scanner.last_cycle_lifecycle_transition_actions, 7);
} }
#[test]
fn scanner_metrics_mapping_preserves_maintenance_control_status() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
maintenance_control: rustfs_common::metrics::ScannerMaintenanceControlSnapshot {
primary_control: "blocked_source".to_string(),
sources: vec![rustfs_common::metrics::ScannerMaintenanceSourceSnapshot {
source: "lifecycle".to_string(),
state: "blocked".to_string(),
reason: "missed_work".to_string(),
backlog: 4,
current_checked: 2,
current_queued: 1,
current_missed: 3,
lifetime_missed: 8,
partial_cycles: 5,
}],
},
..Default::default()
});
assert_eq!(scanner.maintenance_control.primary_control, "blocked_source");
let lifecycle = scanner
.maintenance_control
.sources
.iter()
.find(|source| source.source == "lifecycle")
.expect("lifecycle maintenance control should be mapped");
assert_eq!(lifecycle.state, "blocked");
assert_eq!(lifecycle.reason, "missed_work");
assert_eq!(lifecycle.backlog, 4);
assert_eq!(lifecycle.current_checked, 2);
assert_eq!(lifecycle.current_queued, 1);
assert_eq!(lifecycle.current_missed, 3);
assert_eq!(lifecycle.lifetime_missed, 8);
assert_eq!(lifecycle.partial_cycles, 5);
}
#[test] #[test]
fn scanner_metrics_mapping_preserves_distributed_status_fields() { fn scanner_metrics_mapping_preserves_distributed_status_fields() {
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport { let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
+186
View File
@@ -158,6 +158,108 @@ pub struct ScannerSourceWorkSnapshot {
pub missed: u64, pub missed: u64,
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScannerMaintenanceSourceSnapshot {
#[serde(rename = "source", default)]
pub source: String,
#[serde(rename = "state", default)]
pub state: String,
#[serde(rename = "reason", default)]
pub reason: String,
#[serde(rename = "backlog", default)]
pub backlog: u64,
#[serde(rename = "current_checked", default)]
pub current_checked: u64,
#[serde(rename = "current_queued", default)]
pub current_queued: u64,
#[serde(rename = "current_missed", default)]
pub current_missed: u64,
#[serde(rename = "lifetime_missed", default)]
pub lifetime_missed: u64,
#[serde(rename = "partial_cycles", default)]
pub partial_cycles: u64,
}
impl ScannerMaintenanceSourceSnapshot {
fn merge(&mut self, other: &Self) {
if scanner_maintenance_state_priority(&other.state) > scanner_maintenance_state_priority(&self.state) {
self.state = other.state.clone();
self.reason = other.reason.clone();
} else if self.reason.is_empty() || self.reason == SCANNER_MAINTENANCE_REASON_IDLE {
self.reason = other.reason.clone();
}
self.backlog = self.backlog.saturating_add(other.backlog);
self.current_checked = self.current_checked.saturating_add(other.current_checked);
self.current_queued = self.current_queued.saturating_add(other.current_queued);
self.current_missed = self.current_missed.saturating_add(other.current_missed);
self.lifetime_missed = self.lifetime_missed.saturating_add(other.lifetime_missed);
self.partial_cycles = self.partial_cycles.saturating_add(other.partial_cycles);
}
}
const SCANNER_MAINTENANCE_CONTROL_NONE: &str = "none";
const SCANNER_MAINTENANCE_REASON_IDLE: &str = "idle";
fn scanner_maintenance_state_priority(state: &str) -> u8 {
match state {
"blocked" => 3,
"deferred" => 2,
"active" => 1,
_ => 0,
}
}
fn scanner_maintenance_control_priority(control: &str) -> u8 {
match control {
"blocked_source" => 4,
"deferred_source" => 3,
"active_source" => 2,
"pacing_pressure" => 1,
_ => 0,
}
}
fn default_scanner_maintenance_control() -> String {
SCANNER_MAINTENANCE_CONTROL_NONE.to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScannerMaintenanceControlSnapshot {
#[serde(rename = "primary_control", default = "default_scanner_maintenance_control")]
pub primary_control: String,
#[serde(rename = "sources", default)]
pub sources: Vec<ScannerMaintenanceSourceSnapshot>,
}
impl Default for ScannerMaintenanceControlSnapshot {
fn default() -> Self {
Self {
primary_control: default_scanner_maintenance_control(),
sources: Vec::new(),
}
}
}
impl ScannerMaintenanceControlSnapshot {
fn merge(&mut self, other: &Self) {
if scanner_maintenance_control_priority(&other.primary_control)
> scanner_maintenance_control_priority(&self.primary_control)
{
self.primary_control = other.primary_control.clone();
}
for source in other.sources.iter() {
if let Some(existing) = self.sources.iter_mut().find(|existing| existing.source == source.source) {
existing.merge(source);
} else {
self.sources.push(source.clone());
}
}
self.sources.sort_by(|left, right| left.source.cmp(&right.source));
}
}
impl ScannerSourceWorkSnapshot { impl ScannerSourceWorkSnapshot {
fn merge(&mut self, other: &Self) { fn merge(&mut self, other: &Self) {
self.checked = self.checked.saturating_add(other.checked); self.checked = self.checked.saturating_add(other.checked);
@@ -424,6 +526,8 @@ pub struct ScannerMetrics {
pub pacing_pressure: ScannerPacingPressureSnapshot, pub pacing_pressure: ScannerPacingPressureSnapshot,
#[serde(rename = "lifecycle_transition", default)] #[serde(rename = "lifecycle_transition", default)]
pub lifecycle_transition: ScannerLifecycleTransitionSnapshot, pub lifecycle_transition: ScannerLifecycleTransitionSnapshot,
#[serde(rename = "maintenance_control", default)]
pub maintenance_control: ScannerMaintenanceControlSnapshot,
#[serde(rename = "throttle_idle_mode_enabled", default)] #[serde(rename = "throttle_idle_mode_enabled", default)]
pub throttle_idle_mode_enabled: bool, pub throttle_idle_mode_enabled: bool,
#[serde(rename = "throttle_sleep_factor", default)] #[serde(rename = "throttle_sleep_factor", default)]
@@ -496,6 +600,7 @@ impl ScannerMetrics {
self.oldest_active_path_age_seconds = self.oldest_active_path_age_seconds.max(other.oldest_active_path_age_seconds); self.oldest_active_path_age_seconds = self.oldest_active_path_age_seconds.max(other.oldest_active_path_age_seconds);
self.pacing_pressure.merge(&other.pacing_pressure); self.pacing_pressure.merge(&other.pacing_pressure);
self.lifecycle_transition.merge(&other.lifecycle_transition); self.lifecycle_transition.merge(&other.lifecycle_transition);
self.maintenance_control.merge(&other.maintenance_control);
self.current_set_scan_concurrency_limit = self self.current_set_scan_concurrency_limit = self
.current_set_scan_concurrency_limit .current_set_scan_concurrency_limit
.saturating_add(other.current_set_scan_concurrency_limit); .saturating_add(other.current_set_scan_concurrency_limit);
@@ -1307,6 +1412,87 @@ mod tests {
assert_eq!(scanner.last_cycle_lifecycle_transition_actions, 26); assert_eq!(scanner.last_cycle_lifecycle_transition_actions, 26);
} }
#[test]
fn scanner_metrics_merge_aggregates_maintenance_control_status() {
let collected_at = Utc::now();
let mut scanner = ScannerMetrics {
collected_at,
maintenance_control: ScannerMaintenanceControlSnapshot {
primary_control: "active_source".to_string(),
sources: vec![ScannerMaintenanceSourceSnapshot {
source: "usage".to_string(),
state: "active".to_string(),
reason: "active_work".to_string(),
backlog: 1,
current_checked: 2,
current_queued: 0,
current_missed: 0,
lifetime_missed: 0,
partial_cycles: 0,
}],
},
..Default::default()
};
scanner.merge(&ScannerMetrics {
collected_at: collected_at + chrono::Duration::seconds(1),
maintenance_control: ScannerMaintenanceControlSnapshot {
primary_control: "blocked_source".to_string(),
sources: vec![
ScannerMaintenanceSourceSnapshot {
source: "usage".to_string(),
state: "deferred".to_string(),
reason: "partial_cycle".to_string(),
backlog: 3,
current_checked: 5,
current_queued: 1,
current_missed: 0,
lifetime_missed: 0,
partial_cycles: 2,
},
ScannerMaintenanceSourceSnapshot {
source: "lifecycle".to_string(),
state: "blocked".to_string(),
reason: "missed_work".to_string(),
backlog: 4,
current_checked: 0,
current_queued: 0,
current_missed: 4,
lifetime_missed: 9,
partial_cycles: 1,
},
],
},
..Default::default()
});
assert_eq!(scanner.maintenance_control.primary_control, "blocked_source");
let lifecycle = scanner
.maintenance_control
.sources
.iter()
.find(|source| source.source == "lifecycle")
.expect("lifecycle maintenance control should be present");
assert_eq!(lifecycle.state, "blocked");
assert_eq!(lifecycle.reason, "missed_work");
assert_eq!(lifecycle.backlog, 4);
assert_eq!(lifecycle.current_missed, 4);
assert_eq!(lifecycle.lifetime_missed, 9);
let usage = scanner
.maintenance_control
.sources
.iter()
.find(|source| source.source == "usage")
.expect("usage maintenance control should be present");
assert_eq!(usage.state, "deferred");
assert_eq!(usage.reason, "partial_cycle");
assert_eq!(usage.backlog, 4);
assert_eq!(usage.current_checked, 7);
assert_eq!(usage.current_queued, 1);
assert_eq!(usage.partial_cycles, 2);
}
#[test] #[test]
fn scanner_metrics_merge_preserves_distributed_status_fields() { fn scanner_metrics_merge_preserves_distributed_status_fields() {
let collected_at = Utc::now(); let collected_at = Utc::now();
@@ -282,6 +282,8 @@ Compare these fields between baseline and tuned runs:
| `runtime_config.*.value` and `runtime_config.*.source` | Confirms the tested settings actually took effect. | | `runtime_config.*.value` and `runtime_config.*.source` | Confirms the tested settings actually took effect. |
| `metrics.pacing_pressure.primary_pressure` | Shows whether pressure is from queues, budgets, pause activity, active scans, or no scanner pressure. | | `metrics.pacing_pressure.primary_pressure` | Shows whether pressure is from queues, budgets, pause activity, active scans, or no scanner pressure. |
| `metrics.pacing_pressure.last_cycle_total_pause_ratio` | Shows how much of the last cycle was cooperative scanner pause time. | | `metrics.pacing_pressure.last_cycle_total_pause_ratio` | Shows how much of the last cycle was cooperative scanner pause time. |
| `metrics.maintenance_control.primary_control` | Shows whether source-level maintenance is blocked, deferred, active, only pacing-limited, or idle. |
| `metrics.maintenance_control.sources` | Shows the source, state, reason, backlog, current or last-cycle missed work, and partial-cycle count for each scanner maintenance source. |
| `metrics.current_cycle_objects_scanned` | Confirms object scan progress during the current cycle. | | `metrics.current_cycle_objects_scanned` | Confirms object scan progress during the current cycle. |
| `metrics.current_cycle_directories_scanned` | Confirms directory walk progress during the current cycle. | | `metrics.current_cycle_directories_scanned` | Confirms directory walk progress during the current cycle. |
| `metrics.last_cycle_result` | Confirms whether the previous cycle completed, stopped partially, or failed. | | `metrics.last_cycle_result` | Confirms whether the previous cycle completed, stopped partially, or failed. |
+56 -13
View File
@@ -97,6 +97,7 @@ metrics.pacing_pressure.primary_pressure
metrics.pacing_pressure.last_cycle_budget_limited metrics.pacing_pressure.last_cycle_budget_limited
metrics.lifecycle_transition.current_queued metrics.lifecycle_transition.current_queued
metrics.lifecycle_transition.scanner_missed metrics.lifecycle_transition.scanner_missed
metrics.maintenance_control.primary_control
metrics.source_work metrics.source_work
metrics.scan_checkpoint metrics.scan_checkpoint
``` ```
@@ -146,6 +147,45 @@ Use these counters to decide whether scan progress is limited by scanner pacing
or by a downstream subsystem such as lifecycle transition, replication repair, or by a downstream subsystem such as lifecycle transition, replication repair,
or heal admission. or heal admission.
## Reading Maintenance Control
`metrics.maintenance_control` derives a source-level control snapshot from
scanner pacing, partial-cycle state, source work, and lifecycle transition
queue state. It does not change scanner scheduling by itself; it explains why a
source is moving, deferred, or blocked. When no scan cycle is currently active,
source-work controls use the last completed cycle so recently missed work stays
visible between scanner passes.
`metrics.maintenance_control.primary_control` summarizes the highest-priority
source state:
| Value | Meaning |
|---|---|
| `blocked_source` | At least one maintenance source found work that could not be admitted or is blocked by a downstream queue. |
| `deferred_source` | At least one source was deferred by a partial scanner cycle or budget-limited pass. |
| `active_source` | At least one source has current-cycle work or queued downstream work. |
| `pacing_pressure` | No source-specific state dominated, but scanner pacing pressure is still visible. |
| `none` | No source-level maintenance control pressure was observed. |
Each `metrics.maintenance_control.sources[]` entry has:
| Field | Meaning |
|---|---|
| `source` | Scanner source such as `usage`, `lifecycle`, `bucket_replication`, `site_replication`, `heal`, `bitrot`, or `alerts`. |
| `state` | `idle`, `active`, `deferred`, or `blocked`. |
| `reason` | Derived reason such as `active_work`, `queued_work`, `partial_cycle`, `missed_work`, `transition_queue_backlog`, or `transition_queue_full`. |
| `backlog` | Current source-level backlog estimate from queued or missed work. |
| `current_checked` | Current-cycle checked work for this source, or the last completed cycle when no scan cycle is active. |
| `current_queued` | Current-cycle queued work for this source, or the last completed cycle when no scan cycle is active. |
| `current_missed` | Current-cycle work that could not be admitted, or the last completed cycle when no scan cycle is active. |
| `lifetime_missed` | Lifetime missed work counter for context. |
| `partial_cycles` | Partial cycles attributed to this source. |
Use this snapshot before changing scanner controls. For example,
`blocked_source` with `lifecycle/missed_work` points at downstream lifecycle
admission, while `deferred_source` with `usage/partial_cycle` points at scanner
cycle budgets.
## Reading Distributed Metrics ## Reading Distributed Metrics
`/rustfs/admin/v3/scanner/status` and `/rustfs/admin/v3/metrics` report the `/rustfs/admin/v3/scanner/status` and `/rustfs/admin/v3/metrics` report the
@@ -169,11 +209,12 @@ done
``` ```
The `aggregated.scanner` payload preserves the same scanner progress, The `aggregated.scanner` payload preserves the same scanner progress,
checkpoint, pacing, source work, and lifecycle transition fields used by the checkpoint, pacing, source work, maintenance control, and lifecycle transition
local scanner status, but only for the node that returned the response. The fields used by the local scanner status, but only for the node that returned
`by_host.*.scanner` payload keeps that node's host view. Compare the per-node the response. The `by_host.*.scanner` payload keeps that node's host view.
artifacts externally to find old active paths, partial checkpoints, pacing Compare the per-node artifacts externally to find old active paths, partial
pressure, or downstream queue admission problems across the deployment. checkpoints, pacing pressure, source-level control pressure, or downstream
queue admission problems across the deployment.
## Reading Lifecycle Transition Status ## Reading Lifecycle Transition Status
@@ -206,21 +247,23 @@ sustained CPU usage while the scanner is enabled:
1. Read `/v3/scanner/status`. 1. Read `/v3/scanner/status`.
2. Check `metrics.pacing_pressure.primary_pressure`. 2. Check `metrics.pacing_pressure.primary_pressure`.
3. Check `runtime_config.delay`, `runtime_config.max_wait_seconds`, and 3. Check `metrics.maintenance_control.primary_control` and source entries
before changing runtime controls.
4. Check `runtime_config.delay`, `runtime_config.max_wait_seconds`, and
`runtime_config.cycle_interval_seconds` to confirm the active values and `runtime_config.cycle_interval_seconds` to confirm the active values and
their sources. their sources.
4. Check `metrics.current_cycle_objects_scanned`, 5. Check `metrics.current_cycle_objects_scanned`,
`metrics.current_cycle_directories_scanned`, and active paths to confirm the `metrics.current_cycle_directories_scanned`, and active paths to confirm the
scanner is the active work. scanner is the active work.
5. If `primary_pressure` is `throttle_pause` and pause ratios are low, raise 6. If `primary_pressure` is `throttle_pause` and pause ratios are low, raise
`scanner.delay` first. `scanner.delay` first.
6. If individual sleeps are too short, raise `scanner.max_wait`. 7. If individual sleeps are too short, raise `scanner.max_wait`.
7. If each scan cycle finishes but starts too often, raise `scanner.cycle`. 8. If each scan cycle finishes but starts too often, raise `scanner.cycle`.
8. If scans must be broken into bounded chunks, set one of the cycle budgets: 9. If scans must be broken into bounded chunks, set one of the cycle budgets:
`scanner.cycle_max_duration`, `scanner.cycle_max_objects`, or `scanner.cycle_max_duration`, `scanner.cycle_max_objects`, or
`scanner.cycle_max_directories`. `scanner.cycle_max_directories`.
9. Recheck `pacing_pressure`, source work, and lifecycle transition status after 10. Recheck `pacing_pressure`, `maintenance_control`, source work, and
one or more scanner cycles. lifecycle transition status after one or more scanner cycles.
Do not rely only on a longer cycle interval if lifecycle, replication, heal, or Do not rely only on a longer cycle interval if lifecycle, replication, heal, or
bitrot work must keep moving. Use source work and transition status to confirm bitrot work must keep moving. Use source work and transition status to confirm