diff --git a/crates/scanner/src/scanner.rs b/crates/scanner/src/scanner.rs index ab07ba1eb..9268a92ce 100644 --- a/crates/scanner/src/scanner.rs +++ b/crates/scanner/src/scanner.rs @@ -1086,6 +1086,12 @@ impl ScannerMaintenanceFeatures { fn needs_regular_cycle(self) -> bool { self.lifecycle || self.replication || self.inspection_failed } + + fn requires_full_scan(self, observed_generation: Option, current_generation: u64, wake: ScannerCycleWakeReason) -> bool { + self.needs_regular_cycle() + || observed_generation != Some(current_generation) + || !matches!(wake, ScannerCycleWakeReason::DirtyUsage | ScannerCycleWakeReason::ClusterActivity) + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1283,18 +1289,18 @@ async fn configure_scanner_defaults( ctx: &CancellationToken, storeapi: &Arc, ) -> (ScannerMaintenanceFeatures, Option) { + let (features, maintenance_generation) = detect_stable_scanner_maintenance_features(ctx, storeapi) + .await + .unwrap_or_else(|| { + ( + ScannerMaintenanceFeatures { + inspection_failed: true, + ..Default::default() + }, + scanner_maintenance_generation(), + ) + }); if storeapi.setup_is_erasure_sd().await { - let (features, maintenance_generation) = detect_stable_scanner_maintenance_features(ctx, storeapi) - .await - .unwrap_or_else(|| { - ( - ScannerMaintenanceFeatures { - inspection_failed: true, - ..Default::default() - }, - scanner_maintenance_generation(), - ) - }); // Single-disk keeps the speed-preset-derived default cycle (60s at the // `default` preset) instead of a special shorter cycle: no measured // cold-start ILM latency basis for an override, and clean-idle backoff @@ -1319,7 +1325,7 @@ async fn configure_scanner_defaults( } else { set_scanner_default_speed(ScannerSpeed::Default); set_scanner_default_cycle_secs(None); - (ScannerMaintenanceFeatures::default(), None) + (features, Some(maintenance_generation)) } } @@ -2565,10 +2571,7 @@ where let mut superseded_backoff = ScannerRetryBackoff::default(); let mut deferred_backoff = ScannerRetryBackoff::default(); let initial_runtime_config = resolve_scanner_runtime_config(); - if clean_idle_topology_supported - && scanner_clean_idle_backoff_configured(&initial_runtime_config) - && maintenance_generation_seen.is_none() - { + if clean_idle_topology_supported && maintenance_generation_seen.is_none() { let Some((features, generation)) = detect_stable_scanner_maintenance_features(&ctx, &storeapi).await else { global_metrics().set_cycle(None).await; finish_scanner_leader_iteration(false, "stopped", String::new()).await; @@ -2871,7 +2874,7 @@ where #[cfg(test)] notify_scanner_runtime_observed_for_test(&storeapi, pause_backlog_observation); let runtime_config = resolve_scanner_runtime_config(); - if clean_idle_topology_supported && scanner_clean_idle_backoff_configured(&runtime_config) { + if clean_idle_topology_supported { let current_generation = scanner_maintenance_generation(); if maintenance_generation_seen != Some(current_generation) { scanner_activity_seen = None; @@ -3067,9 +3070,11 @@ where &mut cycle_revision, leader_epoch, cycle_budget.clone(), - maintenance_features.needs_regular_cycle() - || maintenance_generation_seen != Some(scanner_maintenance_generation()) - || !matches!(wake_reason, ScannerCycleWakeReason::DirtyUsage | ScannerCycleWakeReason::ClusterActivity), + maintenance_features.requires_full_scan( + maintenance_generation_seen, + scanner_maintenance_generation(), + wake_reason, + ), ), guard.lock_lost_notified(), ) @@ -3127,10 +3132,7 @@ where let maintenance_config_changed = maintenance_generation_seen.is_some_and(|generation| generation != current_maintenance_generation); let retry_failed_inspection = maintenance_inspection_retry.retry_due(maintenance_features, wake_reason, Instant::now()); - if clean_idle_topology_supported - && scanner_clean_idle_backoff_configured(&runtime_config) - && (maintenance_config_changed || retry_failed_inspection) - { + if clean_idle_topology_supported && (maintenance_config_changed || retry_failed_inspection) { let Some((features, generation)) = detect_stable_scanner_maintenance_features(&ctx, &storeapi).await else { break; }; diff --git a/crates/scanner/src/scanner/tests.rs b/crates/scanner/src/scanner/tests.rs index 84a39c62a..189fa56d1 100644 --- a/crates/scanner/src/scanner/tests.rs +++ b/crates/scanner/src/scanner/tests.rs @@ -8127,6 +8127,76 @@ fn clean_idle_backoff_policy_preserves_explicit_and_maintenance_cycles() { } } +#[tokio::test] +#[serial] +async fn scoped_scan_explicit_bitrot_keeps_dirty_planning_without_idle_backoff() { + temp_env::async_with_vars([(ENV_SCANNER_CYCLE, None), (ENV_SCANNER_BITROT_CYCLE_SECS, Some("3600"))], async { + crate::runtime_config::refresh_scanner_runtime_config_for_tests(); + crate::scanner_io::clear_dirty_usage_buckets_for_tests(); + let (_temp_dir, store) = setup_scanner_cycle_store_with_pool_count(true, 2).await; + let ctx = CancellationToken::new(); + let (features, generation) = configure_scanner_defaults(&ctx, &store).await; + let config = resolve_scanner_runtime_config(); + assert_eq!(config.cycle_interval_source, ScannerRuntimeConfigSource::Default); + assert_eq!(config.bitrot_cycle_source, ScannerRuntimeConfigSource::Env); + assert!(!scanner_clean_idle_backoff_configured(&config)); + assert!(!features.needs_regular_cycle()); + assert_eq!( + generation, + Some(scanner_maintenance_generation()), + "multi-disk startup must inspect maintenance independently" + ); + let observed = ScannerCycleObservedGenerations::for_wait(&config, None, 7, 0, scanner_maintenance_generation()); + assert_eq!(observed.dirty_usage, Some(7), "explicit bitrot still permits ordinary dirty wakeups"); + for (wake, full) in [ + (ScannerCycleWakeReason::DirtyUsage, false), + (ScannerCycleWakeReason::ClusterActivity, false), + (ScannerCycleWakeReason::Timer, true), + (ScannerCycleWakeReason::ClusterMaintenance, true), + ] { + assert_eq!( + features.requires_full_scan(generation, scanner_maintenance_generation(), wake), + full, + "{wake:?}" + ); + } + assert!(features.requires_full_scan(None, scanner_maintenance_generation(), ScannerCycleWakeReason::DirtyUsage)); + for unsafe_features in [ + ScannerMaintenanceFeatures { + lifecycle: true, + ..Default::default() + }, + ScannerMaintenanceFeatures { + replication: true, + ..Default::default() + }, + ScannerMaintenanceFeatures { + inspection_failed: true, + ..Default::default() + }, + ] { + assert!(unsafe_features.requires_full_scan( + generation, + scanner_maintenance_generation(), + ScannerCycleWakeReason::DirtyUsage + )); + } + crate::scanner_io::record_scanner_maintenance_change("maintenance-proof-change"); + assert!(features.requires_full_scan(generation, scanner_maintenance_generation(), ScannerCycleWakeReason::DirtyUsage)); + let (refreshed, refreshed_generation) = detect_stable_scanner_maintenance_features(&ctx, &store) + .await + .expect("changed maintenance generation should be inspected"); + assert!(!refreshed.requires_full_scan( + Some(refreshed_generation), + scanner_maintenance_generation(), + ScannerCycleWakeReason::DirtyUsage + )); + crate::scanner_io::clear_dirty_usage_buckets_for_tests(); + }) + .await; + crate::runtime_config::refresh_scanner_runtime_config_for_tests(); +} + #[test] fn clean_idle_backoff_requires_activity_probes() { let default_config = ScannerRuntimeConfig::default();