fix(scanner): back off clean idle scans across erasure clusters (#4984)

* fix(scanner): back off clean single-disk cycles

* fix(scanner): extend idle backoff across erasure clusters

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
This commit is contained in:
Henry Guo
2026-07-18 10:49:45 +08:00
committed by GitHub
parent 5ec124bf23
commit 889a45ad4d
23 changed files with 3542 additions and 241 deletions
+46
View File
@@ -2434,6 +2434,18 @@ impl Metrics {
self.current_scan_cycle_work_active.store(false, Ordering::Relaxed);
}
pub fn current_scan_cycle_has_unresolved_heal_work(&self) -> bool {
if !self.current_scan_cycle_work_active.load(Ordering::Relaxed) {
return false;
}
let source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
[ScannerWorkSource::Heal, ScannerWorkSource::Bitrot]
.into_iter()
.filter_map(|source| source_work.get(source.index()))
.any(|work| work.queued > 0 || work.skipped > 0 || work.failed > 0 || work.missed > 0)
}
fn scan_cycle_work_snapshot(&self) -> ScanCycleWorkSnapshot {
ScanCycleWorkSnapshot {
objects_scanned: self.lifetime(Metric::ScanObject),
@@ -3361,6 +3373,40 @@ mod tests {
metrics.finish_scan_cycle_work(start);
}
#[test]
fn unresolved_heal_work_only_reflects_the_active_cycle() {
let metrics = Metrics::new();
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_missed(ScannerWorkSource::Heal, 1);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_queued(ScannerWorkSource::Heal, 1);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_work(
ScannerWorkSource::Bitrot,
ScannerSourceWorkUpdate {
skipped: 1,
..Default::default()
},
);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
let start = metrics.start_scan_cycle_work();
metrics.record_scanner_source_failed(ScannerWorkSource::Bitrot, 1);
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
metrics.finish_scan_cycle_work(start);
}
#[tokio::test]
async fn report_marks_transition_failures_as_blocked_lifecycle_control() {
let metrics = Metrics::new();
@@ -798,6 +798,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn scanner_activity(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ScannerActivityRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerActivityResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn background_heal_status(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
+1 -1
View File
@@ -376,7 +376,7 @@ pub mod rio {
pub mod rpc {
pub use crate::cluster::rpc::{
LocalPeerS3Client, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, SERVICE_SIGNAL_REFRESH_CONFIG,
SERVICE_SIGNAL_RELOAD_DYNAMIC, TONIC_RPC_PREFIX, TonicInterceptor, gen_tonic_signature_interceptor,
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, gen_tonic_signature_interceptor,
node_service_time_out_client, node_service_time_out_client_no_auth, verify_rpc_signature,
};
}
+1
View File
@@ -34,6 +34,7 @@ pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
pub use internode_data_transport::build_internode_data_transport_from_env;
pub use peer_rest_client::{
PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
ScannerPeerActivity,
};
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
pub use peer_s3_client::{LocalPeerS3Client, PeerS3Client, S3PeerSys};
@@ -34,9 +34,9 @@ use rustfs_protos::proto_gen::node_service::{
GetNetInfoRequest, GetOsInfoRequest, GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest,
GetSysErrorsRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ServerInfoRequest,
SignalServiceRequest, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest,
node_service_client::NodeServiceClient,
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ScannerActivityRequest,
ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, StartDecommissionRequest, StartProfilingRequest,
StopRebalanceRequest, node_service_client::NodeServiceClient,
};
use rustfs_utils::XHost;
use serde::{Deserialize, Serialize as _};
@@ -62,6 +62,31 @@ pub const SERVICE_SIGNAL_REFRESH_CONFIG: u64 = 1;
pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
const PEER_REST_RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30);
const SCANNER_ACTIVITY_MAX_MESSAGE_SIZE: usize = 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScannerPeerActivity {
pub instance_id: String,
pub namespace_generation: u64,
pub maintenance_generation: u64,
}
fn decode_scanner_activity(response: ScannerActivityResponse) -> Result<ScannerPeerActivity> {
let instance_id = response.instance_id;
if instance_id.len() != 32
|| !instance_id
.as_bytes()
.iter()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
{
return Err(Error::other("peer returned an invalid scanner activity instance ID"));
}
Ok(ScannerPeerActivity {
instance_id,
namespace_generation: response.namespace_generation,
maintenance_generation: response.maintenance_generation,
})
}
#[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch {
@@ -639,12 +664,13 @@ impl PeerRestClient {
Err(Error::NotImplemented)
}
pub async fn load_bucket_metadata(&self, bucket: &str) -> Result<()> {
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
self.finalize_result(
async {
let mut client = self.get_client().await?;
let request = Request::new(LoadBucketMetadataRequest {
bucket: bucket.to_string(),
scanner_maintenance_change,
});
let response = client.load_bucket_metadata(request).await?.into_inner();
@@ -908,6 +934,25 @@ impl PeerRestClient {
.await
}
pub async fn scanner_activity(&self) -> Result<ScannerPeerActivity> {
self.finalize_result(
async {
let mut client = self
.get_client()
.await?
.max_decoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE)
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
let response = client
.scanner_activity(Request::new(ScannerActivityRequest {}))
.await?
.into_inner();
decode_scanner_activity(response)
}
.await,
)
.await
}
pub async fn get_metacache_listing(&self) -> Result<()> {
warn!("get_metacache_listing is not implemented in PeerRestClient");
Err(Error::NotImplemented)
@@ -1157,6 +1202,48 @@ mod tests {
)
}
#[test]
fn scanner_activity_requires_restart_safe_peer_identity() {
let missing_instance = ScannerActivityResponse {
instance_id: String::new(),
namespace_generation: 7,
maintenance_generation: 3,
};
assert!(
decode_scanner_activity(missing_instance)
.expect_err("an empty instance ID is not restart safe")
.to_string()
.contains("instance ID")
);
let malformed_instance = ScannerActivityResponse {
instance_id: "ABCDEF0123456789ABCDEF0123456789".to_string(),
namespace_generation: 7,
maintenance_generation: 3,
};
assert!(
decode_scanner_activity(malformed_instance)
.expect_err("activity instance IDs must use the canonical lowercase hex form")
.to_string()
.contains("instance ID")
);
let activity = decode_scanner_activity(ScannerActivityResponse {
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
namespace_generation: 7,
maintenance_generation: 3,
})
.expect("complete activity responses should be accepted");
assert_eq!(
activity,
ScannerPeerActivity {
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
namespace_generation: 7,
maintenance_generation: 3,
}
);
}
#[test]
fn peer_rest_client_marks_network_like_errors() {
assert!(PeerRestClient::is_network_like_error(&Error::other("transport error")));
+121 -3
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::cluster::rpc::PeerRestClient;
use crate::cluster::rpc::{PeerRestClient, ScannerPeerActivity};
use crate::diagnostics::admin_server_info::get_commit_id;
use crate::disk::DiskAPI;
use crate::error::{Error, Result};
@@ -41,6 +41,7 @@ const CONSECUTIVE_FAILURE_THRESHOLD: u32 = 3;
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// Cached result from the last successful admin call to a peer.
struct PeerAdminCache {
@@ -91,7 +92,6 @@ pub fn get_global_notification_sys() -> Option<Arc<NotificationSys>> {
pub struct NotificationSys {
pub peer_clients: Vec<Option<PeerRestClient>>,
#[allow(dead_code)]
pub all_peer_clients: Vec<Option<PeerRestClient>>,
peer_admin_caches: Vec<Mutex<PeerAdminCache>>,
}
@@ -756,6 +756,14 @@ impl NotificationSys {
}
pub async fn load_bucket_metadata(&self, bucket: &str) -> Result<()> {
self.load_bucket_metadata_with_scanner_maintenance(bucket, false).await
}
pub async fn load_bucket_metadata_for_scanner_maintenance(&self, bucket: &str) -> Result<()> {
self.load_bucket_metadata_with_scanner_maintenance(bucket, true).await
}
async fn load_bucket_metadata_with_scanner_maintenance(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
let operation = format!("load_bucket_metadata({bucket})");
let mut failures = Vec::new();
let mut futures = Vec::with_capacity(self.peer_clients.len());
@@ -763,7 +771,12 @@ impl NotificationSys {
if let Some(client) = client {
let host = client.host.to_string();
let b = bucket.to_string();
futures.push(async move { client.load_bucket_metadata(&b).await.map_err(|err| (host, err)) });
futures.push(async move {
client
.load_bucket_metadata(&b, scanner_maintenance_change)
.await
.map_err(|err| (host, err))
});
} else {
failures.push(format!("peer[{idx}] {operation} failed: peer is not reachable"));
}
@@ -976,6 +989,36 @@ impl NotificationSys {
join_all(futures).await
}
pub async fn scanner_activity_snapshots(&self) -> Result<Vec<(String, ScannerPeerActivity)>> {
if self.peer_clients.is_empty() {
return Err(Error::other("scanner activity probe has no remote peers"));
}
if self.all_peer_clients.len() != self.peer_clients.len() + 1 {
return Err(Error::other(format!(
"scanner activity peer topology is incomplete: {} remote peers for {} cluster members",
self.peer_clients.len(),
self.all_peer_clients.len()
)));
}
let mut futures = Vec::with_capacity(self.peer_clients.len());
for (idx, client) in self.peer_clients.iter().cloned().enumerate() {
futures.push(async move {
let client = client.ok_or_else(|| Error::other(format!("scanner activity peer[{idx}] is unreachable")))?;
let host = client.grid_host.clone();
scanner_activity_with_timeout(SCANNER_ACTIVITY_PROBE_TIMEOUT, &host, client.scanner_activity())
.await
.map(|activity| (host, activity))
});
}
let mut generations = Vec::with_capacity(futures.len());
for result in join_all(futures).await {
generations.push(result?);
}
Ok(generations)
}
pub async fn reload_site_replication_config(&self) -> Vec<NotificationPeerErr> {
let mut futures = Vec::with_capacity(self.peer_clients.len());
for client in self.peer_clients.iter() {
@@ -1029,6 +1072,15 @@ impl NotificationSys {
}
}
async fn scanner_activity_with_timeout<F>(timeout_duration: Duration, host: &str, activity: F) -> Result<ScannerPeerActivity>
where
F: Future<Output = Result<ScannerPeerActivity>>,
{
timeout(timeout_duration, activity)
.await
.map_err(|_| Error::other(format!("scanner activity peer {host} timed out after {timeout_duration:?}")))?
}
async fn call_peer_with_timeout<F, Fut>(
timeout_dur: Duration,
host_label: &str,
@@ -1568,6 +1620,72 @@ mod tests {
assert!(msg.contains("peer[0]"));
}
#[tokio::test]
async fn scanner_activity_probe_reports_unreachable_peers() {
let sys = NotificationSys {
peer_clients: vec![None],
all_peer_clients: vec![None, None],
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
};
let err = sys
.scanner_activity_snapshots()
.await
.expect_err("unreachable peers must disable scanner idle backoff");
assert!(err.to_string().contains("scanner activity peer[0] is unreachable"));
}
#[tokio::test]
async fn scanner_activity_probe_rejects_an_empty_peer_set() {
let sys = NotificationSys {
peer_clients: Vec::new(),
all_peer_clients: Vec::new(),
peer_admin_caches: Vec::new(),
};
let err = sys
.scanner_activity_snapshots()
.await
.expect_err("a missing peer set must disable scanner idle backoff");
assert!(err.to_string().contains("no remote peers"));
}
#[tokio::test]
async fn scanner_activity_probe_rejects_an_incomplete_peer_topology() {
let client = PeerRestClient::new(
"127.0.0.1:9000".to_string().try_into().expect("peer host should parse"),
"http://127.0.0.1:9000".to_string(),
);
let sys = NotificationSys {
peer_clients: vec![Some(client)],
all_peer_clients: vec![None],
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
};
let err = sys
.scanner_activity_snapshots()
.await
.expect_err("an incomplete peer topology must disable scanner idle backoff");
assert!(err.to_string().contains("peer topology is incomplete"));
}
#[tokio::test]
async fn scanner_activity_probe_times_out() {
let err = scanner_activity_with_timeout(
Duration::from_millis(5),
"peer-1",
std::future::pending::<Result<ScannerPeerActivity>>(),
)
.await
.expect_err("a stalled peer must not block scanner scheduling");
assert!(err.to_string().contains("timed out"));
assert!(err.to_string().contains("peer-1"));
}
#[tokio::test]
async fn load_bucket_metadata_reports_unreachable_peers() {
let sys = NotificationSys {
+30
View File
@@ -392,16 +392,28 @@ mod tests {
}
async fn create_bucket_with_object(ecstore: &Arc<ECStore>, bucket: &str, object: &str) {
let generation_before_make = ecstore.scanner_namespace_mutation_generation();
ecstore
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
assert_eq!(
ecstore.scanner_namespace_mutation_generation(),
generation_before_make.saturating_add(1),
"successful bucket creation should advance scanner namespace activity"
);
let generation_before_put = ecstore.scanner_namespace_mutation_generation();
let mut reader = PutObjReader::from_vec(b"delete bucket semantics".to_vec());
ecstore
.put_object(bucket, object, &mut reader, &ObjectOptions::default())
.await
.expect("object should be written");
assert_eq!(
ecstore.scanner_namespace_mutation_generation(),
generation_before_put.saturating_add(1),
"successful object creation should advance scanner namespace activity"
);
ecstore
.get_object_info(bucket, object, &ObjectOptions::default())
.await
@@ -499,6 +511,7 @@ mod tests {
create_bucket_with_object(&ecstore, &bucket, object).await;
assert!(metadata_sys::get(&bucket).await.is_ok());
let generation_before_delete = ecstore.scanner_namespace_mutation_generation();
ecstore
.delete_bucket(
&bucket,
@@ -509,6 +522,11 @@ mod tests {
)
.await
.expect("MarkDelete should not reject non-empty bucket data");
assert_eq!(
ecstore.scanner_namespace_mutation_generation(),
generation_before_delete.saturating_add(1),
"successful bucket deletion should advance scanner namespace activity"
);
assert!(
any_disk_has_object_metadata(&disk_paths, &bucket).await,
@@ -536,6 +554,7 @@ mod tests {
write_bucket_metadata_marker(&disk_paths, &metadata_prefix).await;
assert!(any_disk_path_exists(&disk_paths, &metadata_prefix).await);
let generation_before_delete = ecstore.scanner_namespace_mutation_generation();
ecstore
.delete_bucket(
&bucket,
@@ -547,6 +566,11 @@ mod tests {
)
.await
.expect("Purge should force-delete bucket data");
assert_eq!(
ecstore.scanner_namespace_mutation_generation(),
generation_before_delete.saturating_add(1),
"successful bucket purge should advance scanner namespace activity"
);
assert!(!any_disk_path_exists(&disk_paths, &bucket).await, "Purge should remove the bucket volume");
assert!(
@@ -568,12 +592,18 @@ mod tests {
create_bucket_with_object(&ecstore, &bucket, object).await;
let generation_before_delete = ecstore.scanner_namespace_mutation_generation();
let err = ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect_err("default S3 DeleteBucket should reject non-empty buckets");
assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket));
assert_eq!(
ecstore.scanner_namespace_mutation_generation(),
generation_before_delete,
"failed bucket deletion must not advance scanner namespace activity"
);
assert!(
any_disk_has_object_metadata(&disk_paths, &bucket).await,
"failed default S3 DeleteBucket must keep object data"
+37 -7
View File
@@ -541,6 +541,7 @@ impl PersistentListMetadataObject {
static PERSISTENT_KEY_ONLY_INDEX_CACHE: OnceCell<RwLock<Option<PersistentKeyOnlyIndexCache>>> = OnceCell::const_new();
static LIST_OBJECTS_NAMESPACE_JOURNAL_LOCK: OnceCell<RwLock<()>> = OnceCell::const_new();
static LIST_OBJECTS_MUTATION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
static SCANNER_NAMESPACE_MUTATION_GENERATION: AtomicU64 = AtomicU64::new(0);
static LIST_OBJECTS_BUCKET_MUTATION_SEQUENCE: OnceCell<RwLock<HashMap<String, u64>>> = OnceCell::const_new();
static LIST_OBJECTS_NAMESPACE_JOURNAL_DEGRADED_BUCKETS: OnceCell<RwLock<HashSet<String>>> = OnceCell::const_new();
static LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_CONFIG: OnceCell<Option<NamespaceMutationJournalChaosConfig>> = OnceCell::const_new();
@@ -607,6 +608,19 @@ async fn advance_list_objects_mutation_sequence(bucket: &str, sequence: u64) ->
sequence
}
pub(super) fn scanner_namespace_mutation_generation() -> u64 {
SCANNER_NAMESPACE_MUTATION_GENERATION.load(Ordering::Acquire)
}
pub(super) fn observe_scanner_namespace_mutations(bucket: &str, delta: u64) {
if bucket == RUSTFS_META_BUCKET {
return;
}
let _ = SCANNER_NAMESPACE_MUTATION_GENERATION
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| Some(current.saturating_add(delta)));
}
pub(super) async fn observe_list_objects_mutation(store: &ECStore, bucket: &str) -> u64 {
observe_list_objects_mutations(store, bucket, 1).await.unwrap_or_default()
}
@@ -621,6 +635,7 @@ async fn observe_list_objects_mutations_with_store(store: Option<&ECStore>, buck
}
let delta = u64::try_from(count).unwrap_or(u64::MAX);
observe_scanner_namespace_mutations(bucket, delta);
let next = LIST_OBJECTS_MUTATION_SEQUENCE
.fetch_add(delta, Ordering::AcqRel)
.saturating_add(delta);
@@ -641,6 +656,7 @@ async fn current_list_objects_mutation_sequence(bucket: &str) -> u64 {
#[cfg(test)]
async fn reset_list_objects_mutation_sequences_for_test() {
LIST_OBJECTS_MUTATION_SEQUENCE.store(0, Ordering::Release);
SCANNER_NAMESPACE_MUTATION_GENERATION.store(0, Ordering::Release);
let sequences = list_objects_bucket_mutation_sequence().await;
sequences.write().await.clear();
let degraded = list_objects_namespace_journal_degraded_buckets().await;
@@ -6646,11 +6662,11 @@ mod test {
ListingSupplement, ListingSupplementOptions, MAX_OBJECT_LIST, NamespaceMutationJournalBackend,
NamespaceMutationJournalSnapshot, NamespaceMutationJournalStatus, PERSISTENT_KEY_ONLY_INDEX_BUCKET_HEADER,
PERSISTENT_KEY_ONLY_INDEX_CHECKPOINT_HEADER, PERSISTENT_KEY_ONLY_INDEX_GENERATION_HEADER,
PERSISTENT_KEY_ONLY_INDEX_HEADER, PersistentKeyOnlyIndex, PersistentListMetadataObject, VerifiedIndexCandidateStats,
VersionMarker, current_list_objects_mutation_sequence, encode_persistent_list_metadata_object,
enforce_latest_listing_write_quorum, expand_ask_disks_for_object_quorum, fallback_entries_for_object, gather_results,
latest_listing_allow_agreed_objects, latest_listing_object_quorum, latest_listing_raw_min_disks,
latest_listing_required_object_quorum, list_marker_key, list_metadata_resolution_params,
PERSISTENT_KEY_ONLY_INDEX_HEADER, PersistentKeyOnlyIndex, PersistentListMetadataObject, RUSTFS_META_BUCKET,
VerifiedIndexCandidateStats, VersionMarker, current_list_objects_mutation_sequence,
encode_persistent_list_metadata_object, enforce_latest_listing_write_quorum, expand_ask_disks_for_object_quorum,
fallback_entries_for_object, gather_results, latest_listing_allow_agreed_objects, latest_listing_object_quorum,
latest_listing_raw_min_disks, latest_listing_required_object_quorum, list_marker_key, list_metadata_resolution_params,
list_objects_from_metadata_snapshot_candidates, list_objects_from_verified_index_candidates,
list_objects_from_verified_index_candidates_with_optional_stats, list_objects_from_verified_index_candidates_with_stats,
list_objects_index_mode_from_env, list_objects_index_provider_from_env, list_objects_index_provider_state_from_env,
@@ -6664,8 +6680,9 @@ mod test {
parse_version_marker, persist_observed_list_objects_mutation, persistent_key_only_index_has_complete_metadata_snapshot,
persistent_key_only_index_health, persistent_key_only_index_matches_provider, record_list_objects_index_opt_in_fallback,
reset_list_objects_mutation_sequences_for_test, resolve_agreed_listing_entry, resolve_listing_entries,
select_list_index_provider_source_mode, select_list_index_source_mode, send_or_cancel, version_marker_for_entries,
walk_result_from_set_errors, write_namespace_mutation_journal_state, write_persistent_key_only_index,
scanner_namespace_mutation_generation, select_list_index_provider_source_mode, select_list_index_source_mode,
send_or_cancel, version_marker_for_entries, walk_result_from_set_errors, write_namespace_mutation_journal_state,
write_persistent_key_only_index,
};
use crate::cache_value::metacache_set::{FallbackClaimTracker, TestReaderBehavior, list_path_raw};
use crate::disk::{DiskAPI, DiskOption, endpoint::Endpoint, error::DiskError, new_disk};
@@ -8362,6 +8379,19 @@ mod test {
assert_eq!(current_list_objects_mutation_sequence("bucket-b").await, 0);
}
#[tokio::test]
#[serial_test::serial]
async fn scanner_namespace_generation_tracks_only_user_namespace_mutations() {
reset_list_objects_mutation_sequences_for_test().await;
assert_eq!(scanner_namespace_mutation_generation(), 0);
assert_eq!(observe_list_objects_mutations_with_store(None, RUSTFS_META_BUCKET, 3).await, Some(3));
assert_eq!(scanner_namespace_mutation_generation(), 0);
assert_eq!(observe_list_objects_mutations_with_store(None, "photos", 2).await, Some(5));
assert_eq!(scanner_namespace_mutation_generation(), 2);
}
#[test]
fn list_objects_index_provider_state_uses_lifecycle_active_generation() {
let provider = ListObjectsIndexProviderState::from_kind(ListObjectsIndexProviderKind::WalkerKeyOnly);
+14 -2
View File
@@ -318,6 +318,10 @@ impl ECStore {
pub async fn setup_is_erasure_sd(&self) -> bool {
self.ctx.is_erasure_sd().await
}
pub fn scanner_namespace_mutation_generation(&self) -> u64 {
list_objects::scanner_namespace_mutation_generation()
}
}
// impl Clone for ECStore {
@@ -436,7 +440,11 @@ impl BucketOperations for ECStore {
#[instrument(skip(self))]
async fn make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()> {
self.handle_make_bucket(bucket, opts).await
let result = self.handle_make_bucket(bucket, opts).await;
if result.is_ok() {
list_objects::observe_scanner_namespace_mutations(bucket, 1);
}
result
}
#[instrument(skip(self))]
@@ -449,7 +457,11 @@ impl BucketOperations for ECStore {
}
#[instrument(skip(self))]
async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> {
self.handle_delete_bucket(bucket, opts).await
let result = self.handle_delete_bucket(bucket, opts).await;
if result.is_ok() {
list_objects::observe_scanner_namespace_mutations(bucket, 1);
}
result
}
}
@@ -923,6 +923,8 @@ pub struct GetAllBucketStatsResponse {
pub struct LoadBucketMetadataRequest {
#[prost(string, tag = "1")]
pub bucket: ::prost::alloc::string::String,
#[prost(bool, tag = "2")]
pub scanner_maintenance_change: bool,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct LoadBucketMetadataResponse {
@@ -1067,6 +1069,17 @@ pub struct SignalServiceResponse {
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct ScannerActivityRequest {}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct ScannerActivityResponse {
#[prost(string, tag = "1")]
pub instance_id: ::prost::alloc::string::String,
#[prost(uint64, tag = "2")]
pub namespace_generation: u64,
#[prost(uint64, tag = "3")]
pub maintenance_generation: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct BackgroundHealStatusRequest {}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct BackgroundHealStatusResponse {
@@ -2351,6 +2364,21 @@ pub mod node_service_client {
.insert(GrpcMethod::new("node_service.NodeService", "SignalService"));
self.inner.unary(req, path, codec).await
}
pub async fn scanner_activity(
&mut self,
request: impl tonic::IntoRequest<super::ScannerActivityRequest>,
) -> std::result::Result<tonic::Response<super::ScannerActivityResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.NodeService/ScannerActivity");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.NodeService", "ScannerActivity"));
self.inner.unary(req, path, codec).await
}
pub async fn background_heal_status(
&mut self,
request: impl tonic::IntoRequest<super::BackgroundHealStatusRequest>,
@@ -2825,6 +2853,10 @@ pub mod node_service_server {
&self,
request: tonic::Request<super::SignalServiceRequest>,
) -> std::result::Result<tonic::Response<super::SignalServiceResponse>, tonic::Status>;
async fn scanner_activity(
&self,
request: tonic::Request<super::ScannerActivityRequest>,
) -> std::result::Result<tonic::Response<super::ScannerActivityResponse>, tonic::Status>;
async fn background_heal_status(
&self,
request: tonic::Request<super::BackgroundHealStatusRequest>,
@@ -4933,6 +4965,34 @@ pub mod node_service_server {
};
Box::pin(fut)
}
"/node_service.NodeService/ScannerActivity" => {
#[allow(non_camel_case_types)]
struct ScannerActivitySvc<T: NodeService>(pub Arc<T>);
impl<T: NodeService> tonic::server::UnaryService<super::ScannerActivityRequest> for ScannerActivitySvc<T> {
type Response = super::ScannerActivityResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::ScannerActivityRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move { <T as NodeService>::scanner_activity(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = ScannerActivitySvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.NodeService/BackgroundHealStatus" => {
#[allow(non_camel_case_types)]
struct BackgroundHealStatusSvc<T: NodeService>(pub Arc<T>);
+10
View File
@@ -649,6 +649,7 @@ message GetAllBucketStatsResponse {
message LoadBucketMetadataRequest {
string bucket = 1;
bool scanner_maintenance_change = 2;
}
message LoadBucketMetadataResponse {
@@ -756,6 +757,14 @@ message SignalServiceResponse {
optional string error_info = 2;
}
message ScannerActivityRequest {}
message ScannerActivityResponse {
string instance_id = 1;
uint64 namespace_generation = 2;
uint64 maintenance_generation = 3;
}
message BackgroundHealStatusRequest {}
message BackgroundHealStatusResponse {
@@ -944,6 +953,7 @@ service NodeService {
// rpc VerifyBinary() returns () {};
// rpc CommitBinary() returns () {};
rpc SignalService(SignalServiceRequest) returns (SignalServiceResponse) {};
rpc ScannerActivity(ScannerActivityRequest) returns (ScannerActivityResponse) {};
rpc BackgroundHealStatus(BackgroundHealStatusRequest) returns (BackgroundHealStatusResponse) {};
rpc GetMetacacheListing(GetMetacacheListingRequest) returns (GetMetacacheListingResponse) {};
rpc UpdateMetacacheListing(UpdateMetacacheListingRequest) returns (UpdateMetacacheListingResponse) {};
+5 -2
View File
@@ -58,8 +58,11 @@ pub use data_usage_define::*;
pub use error::ScannerError;
pub use runtime_config::{apply_scanner_runtime_config, scanner_runtime_config_status, validate_scanner_runtime_config};
pub use rustfs_common::last_minute;
pub use scanner::init_data_scanner;
pub use scanner_io::{clear_dirty_usage_bucket, record_dirty_usage_bucket};
pub use scanner::{ScannerCycleScheduleStatus, init_data_scanner, scanner_cycle_schedule_status};
pub use scanner_io::{
clear_dirty_usage_bucket, record_dirty_usage_bucket, record_scanner_maintenance_change, scanner_activity_epoch,
scanner_maintenance_generation,
};
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
use std::sync::atomic::{AtomicU64, Ordering};
pub use storage_api::ScannerReplicationConfig as ReplicationConfig;
+71 -5
View File
@@ -38,11 +38,14 @@ use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, RwLock};
use std::time::Duration;
use tokio::sync::Notify;
use tracing::warn;
const LOG_COMPONENT_SCANNER: &str = "scanner";
#[cfg(test)]
const LOG_SUBSYSTEM_RUNTIME: &str = "runtime";
const LOG_SUBSYSTEM_RUNTIME_CONFIG: &str = "runtime_config";
#[cfg(test)]
const EVENT_SCANNER_RUNTIME_CONFIG: &str = "scanner_runtime_config";
const EVENT_SCANNER_RUNTIME_CONFIG_PARSE: &str = "scanner_runtime_config_parse";
@@ -52,6 +55,8 @@ const MAX_SCANNER_DELAY_FACTOR: f64 = 10_000.0;
const SCANNER_DELAY_RANGE_REASON: &str = "expected scanner delay between 0 and 10000";
static SCANNER_DEFAULT_CYCLE_SECS: AtomicU64 = AtomicU64::new(NO_DEFAULT_CYCLE_OVERRIDE);
static SCANNER_RUNTIME_CONFIG_GENERATION: AtomicU64 = AtomicU64::new(0);
static SCANNER_RUNTIME_CONFIG_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
static SCANNER_RUNTIME_CONFIG: LazyLock<RwLock<ScannerRuntimeConfig>> =
LazyLock::new(|| RwLock::new(lookup_scanner_runtime_config(None).unwrap_or_default()));
@@ -678,6 +683,7 @@ fn current_server_config() -> Option<ServerConfig> {
resolve_scanner_server_config()
}
#[cfg(test)]
pub(crate) fn resolve_scanner_runtime_config_from_global() -> ScannerRuntimeConfig {
let config = current_server_config();
match lookup_scanner_runtime_config(config.as_ref()) {
@@ -705,9 +711,19 @@ pub fn apply_scanner_runtime_config(config: &ServerConfig) -> Result<(), Scanner
validate_scanner_runtime_config(config)?;
let resolved = lookup_scanner_runtime_config(Some(config))?;
apply_resolved_runtime_config(resolved);
SCANNER_RUNTIME_CONFIG_GENERATION.fetch_add(1, Ordering::AcqRel);
SCANNER_RUNTIME_CONFIG_NOTIFY.notify_one();
Ok(())
}
pub(crate) fn scanner_runtime_config_generation() -> u64 {
SCANNER_RUNTIME_CONFIG_GENERATION.load(Ordering::Acquire)
}
pub(crate) async fn scanner_runtime_config_changed() {
SCANNER_RUNTIME_CONFIG_NOTIFY.notified().await;
}
pub(crate) fn refresh_scanner_runtime_config_from_global() -> Result<(), ScannerRuntimeConfigError> {
let config = current_server_config();
let resolved = lookup_scanner_runtime_config(config.as_ref())?;
@@ -846,11 +862,11 @@ mod tests {
use crate::init_ecstore_config_for_scanner_tests;
use rustfs_config::server_config::{Config as ServerConfig, KVS};
use rustfs_config::{
DEFAULT_DELIMITER, ENV_SCANNER_BITROT_CYCLE_SECS, ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS, ENV_SCANNER_CYCLE,
ENV_SCANNER_CYCLE_MAX_OBJECTS, ENV_SCANNER_DELAY, ENV_SCANNER_MAX_WAIT_SECS, ENV_SCANNER_SPEED, HEAL_BITROT_CYCLE,
HEAL_SUB_SYS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES,
SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY, SCANNER_IDLE_MODE, SCANNER_SPEED, SCANNER_SUB_SYS,
ScannerSpeed,
DEFAULT_DELIMITER, DEFAULT_HEAL_BITROT_CYCLE_SECS, ENV_SCANNER_BITROT_CYCLE_SECS, ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS,
ENV_SCANNER_CYCLE, ENV_SCANNER_CYCLE_MAX_OBJECTS, ENV_SCANNER_DELAY, ENV_SCANNER_MAX_WAIT_SECS, ENV_SCANNER_SPEED,
HEAL_BITROT_CYCLE, HEAL_SUB_SYS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY, SCANNER_IDLE_MODE,
SCANNER_SPEED, SCANNER_SUB_SYS, ScannerSpeed,
};
use serial_test::serial;
use std::collections::HashMap;
@@ -927,6 +943,22 @@ mod tests {
});
}
#[test]
#[serial]
fn scanner_runtime_config_normalizes_persisted_default_speed() {
let config = server_config_with_scanner(&[(SCANNER_SPEED, "default")]);
with_var_unset(ENV_SCANNER_SPEED, || {
with_var_unset(ENV_SCANNER_CYCLE, || {
let resolved = lookup_scanner_runtime_config(Some(&config)).expect("scanner runtime config");
assert_eq!(resolved.speed, ScannerSpeed::Default);
assert_eq!(resolved.speed_source, ScannerRuntimeConfigSource::Default);
assert_eq!(resolved.cycle_interval_source, ScannerRuntimeConfigSource::Default);
});
});
}
#[test]
#[serial]
fn scanner_runtime_config_prefers_env_over_persisted_config() {
@@ -974,6 +1006,23 @@ mod tests {
super::refresh_scanner_runtime_config_for_tests();
}
#[test]
#[serial]
fn scanner_runtime_config_normalizes_persisted_default_bitrot_cycles() {
let default_cycle = DEFAULT_HEAL_BITROT_CYCLE_SECS.to_string();
for config in [
server_config_with_scanner_and_heal(&[], &[(HEAL_BITROT_CYCLE, default_cycle.as_str())]),
server_config_with_scanner(&[(SCANNER_BITROT_CYCLE, default_cycle.as_str())]),
] {
with_var_unset(ENV_SCANNER_BITROT_CYCLE_SECS, || {
let resolved = lookup_scanner_runtime_config(Some(&config)).expect("scanner runtime config");
assert_eq!(resolved.bitrot_cycle, Some(Duration::from_secs(DEFAULT_HEAL_BITROT_CYCLE_SECS)));
assert_eq!(resolved.bitrot_cycle_source, ScannerRuntimeConfigSource::Default);
});
}
}
#[test]
fn scanner_runtime_config_rejects_invalid_persisted_speed() {
let config = server_config_with_scanner(&[(SCANNER_SPEED, "warp")]);
@@ -1058,6 +1107,23 @@ mod tests {
super::refresh_scanner_runtime_config_for_tests();
}
#[test]
#[serial]
fn applied_runtime_config_is_the_authoritative_scheduler_state() {
let config = server_config_with_scanner(&[(SCANNER_CYCLE, "321")]);
with_var_unset(ENV_SCANNER_CYCLE, || {
let generation = super::scanner_runtime_config_generation();
super::apply_scanner_runtime_config(&config).expect("scanner runtime config should apply");
let applied = super::current_scanner_runtime_config();
assert_eq!(applied.cycle_interval, Duration::from_secs(321));
assert_eq!(applied.cycle_interval_source, ScannerRuntimeConfigSource::Config);
assert!(super::scanner_runtime_config_generation() > generation);
});
super::refresh_scanner_runtime_config_for_tests();
}
#[test]
#[serial]
fn scanner_runtime_config_status_reports_persisted_pacing_overrides() {
File diff suppressed because it is too large Load Diff
+414 -46
View File
@@ -31,7 +31,7 @@ use rustfs_utils::path::path_join_buf;
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ObjectLockEnabled, ReplicationConfiguration};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{LazyLock, Mutex as StdMutex, MutexGuard};
use std::time::{Instant, SystemTime};
use std::{fmt::Debug, sync::Arc};
@@ -72,6 +72,13 @@ const METRIC_SCANNER_DISK_BUCKET_SCANS_QUEUED: &str = "rustfs_scanner_disk_bucke
pub type DirtyUsageBuckets = HashMap<String, u64>;
#[derive(Clone, Debug)]
struct DirtyUsageSnapshot {
buckets: Arc<DirtyUsageBuckets>,
generation: u64,
covers_all_pending: bool,
}
pub(crate) fn is_scanner_metadata_corrupt_error(err: &StorageError) -> bool {
matches!(err, StorageError::Io(io) if io.to_string().starts_with(SCANNER_METADATA_CORRUPT_ERROR))
}
@@ -115,6 +122,7 @@ pub struct ScannerBucketScanPlan {
buckets: Vec<BucketInfo>,
dirty_usage_buckets: Arc<DirtyUsageBuckets>,
failed_dirty_buckets: Arc<Mutex<HashSet<String>>>,
pending_maintenance_work: Arc<AtomicBool>,
}
impl ScannerBucketScanPlan {
@@ -122,11 +130,13 @@ impl ScannerBucketScanPlan {
buckets: Vec<BucketInfo>,
dirty_usage_buckets: Arc<DirtyUsageBuckets>,
failed_dirty_buckets: Arc<Mutex<HashSet<String>>>,
pending_maintenance_work: Arc<AtomicBool>,
) -> Self {
Self {
buckets,
dirty_usage_buckets,
failed_dirty_buckets,
pending_maintenance_work,
}
}
}
@@ -134,6 +144,9 @@ impl ScannerBucketScanPlan {
static DIRTY_USAGE_BUCKET_GENERATION: AtomicU64 = AtomicU64::new(0);
static DIRTY_USAGE_BUCKETS: LazyLock<StdMutex<DirtyUsageBuckets>> = LazyLock::new(|| StdMutex::new(HashMap::new()));
static DIRTY_USAGE_BUCKET_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
static SCANNER_ACTIVITY_EPOCH: LazyLock<String> = LazyLock::new(|| format!("{:032x}", rand::random::<u128>()));
static SCANNER_MAINTENANCE_GENERATION: AtomicU64 = AtomicU64::new(0);
static SCANNER_MAINTENANCE_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
fn dirty_usage_buckets() -> MutexGuard<'static, DirtyUsageBuckets> {
DIRTY_USAGE_BUCKETS.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -148,9 +161,9 @@ pub fn record_dirty_usage_bucket(bucket: &str) {
return;
}
let generation = DIRTY_USAGE_BUCKET_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
let pending_buckets = {
let mut dirty_buckets = dirty_usage_buckets();
let generation = DIRTY_USAGE_BUCKET_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
dirty_buckets.insert(bucket.to_string(), generation);
dirty_buckets.len()
};
@@ -158,6 +171,32 @@ pub fn record_dirty_usage_bucket(bucket: &str) {
DIRTY_USAGE_BUCKET_NOTIFY.notify_one();
}
pub fn record_scanner_maintenance_change(bucket: &str) {
if bucket.is_empty() {
return;
}
SCANNER_MAINTENANCE_GENERATION.fetch_add(1, Ordering::AcqRel);
SCANNER_MAINTENANCE_NOTIFY.notify_one();
record_dirty_usage_bucket(bucket);
}
pub fn scanner_maintenance_generation() -> u64 {
SCANNER_MAINTENANCE_GENERATION.load(Ordering::Acquire)
}
pub(crate) async fn scanner_maintenance_changed() {
SCANNER_MAINTENANCE_NOTIFY.notified().await;
}
pub fn scanner_activity_epoch() -> &'static str {
SCANNER_ACTIVITY_EPOCH.as_str()
}
pub(crate) fn dirty_usage_generation() -> u64 {
DIRTY_USAGE_BUCKET_GENERATION.load(Ordering::Acquire)
}
pub fn clear_dirty_usage_bucket(bucket: &str) {
if bucket.is_empty() {
return;
@@ -166,25 +205,39 @@ pub fn clear_dirty_usage_bucket(bucket: &str) {
let pending_buckets = {
let mut dirty_buckets = dirty_usage_buckets();
dirty_buckets.remove(bucket);
DIRTY_USAGE_BUCKET_GENERATION.fetch_add(1, Ordering::AcqRel);
dirty_buckets.len()
};
global_metrics().record_scanner_dirty_usage_clear(usize_to_u64_saturated(pending_buckets));
}
fn snapshot_dirty_usage_buckets(buckets: &[BucketInfo]) -> DirtyUsageBuckets {
let snapshot = {
fn snapshot_dirty_usage_buckets(buckets: &[BucketInfo], absent_generation_cutoff: u64) -> DirtyUsageSnapshot {
let (snapshot, generation, covers_all_pending) = {
let dirty_buckets = dirty_usage_buckets();
buckets
let listed_buckets = dirty_buckets
.values()
.any(|generation| *generation > absent_generation_cutoff)
.then(|| buckets.iter().map(|bucket| bucket.name.as_str()).collect::<HashSet<_>>());
let snapshot = dirty_buckets
.iter()
.filter_map(|bucket| {
dirty_buckets
.get(&bucket.name)
.map(|generation| (bucket.name.clone(), *generation))
.filter(|(bucket, generation)| {
**generation <= absent_generation_cutoff
|| listed_buckets
.as_ref()
.is_some_and(|listed_buckets| listed_buckets.contains(bucket.as_str()))
})
.collect::<DirtyUsageBuckets>()
.map(|(bucket, generation)| (bucket.clone(), *generation))
.collect::<DirtyUsageBuckets>();
let generation = DIRTY_USAGE_BUCKET_GENERATION.load(Ordering::Acquire);
let covers_all_pending = generation == absent_generation_cutoff && snapshot.len() == dirty_buckets.len();
(snapshot, generation, covers_all_pending)
};
global_metrics().record_scanner_dirty_usage_cycle_snapshot(usize_to_u64_saturated(snapshot.len()));
snapshot
DirtyUsageSnapshot {
buckets: Arc::new(snapshot),
generation,
covers_all_pending,
}
}
pub(crate) fn dirty_usage_buckets_pending() -> bool {
@@ -205,6 +258,9 @@ fn clear_dirty_usage_buckets(snapshot: &DirtyUsageBuckets) {
cleared_buckets += 1;
}
}
if cleared_buckets > 0 {
DIRTY_USAGE_BUCKET_GENERATION.fetch_add(1, Ordering::AcqRel);
}
(cleared_buckets, dirty_buckets.len())
};
global_metrics()
@@ -237,13 +293,8 @@ async fn record_failed_dirty_bucket(failed_buckets: &Arc<Mutex<HashSet<String>>>
failed_buckets.lock().await.insert(bucket.to_string());
}
fn dirty_usage_snapshot_covers_current(snapshot: &DirtyUsageBuckets) -> bool {
let dirty_buckets = dirty_usage_buckets();
dirty_buckets.iter().all(|(bucket, generation)| {
snapshot
.get(bucket)
.is_some_and(|snapshot_generation| snapshot_generation == generation)
})
fn dirty_usage_snapshot_covers_current(snapshot: &DirtyUsageSnapshot) -> bool {
snapshot.covers_all_pending && DIRTY_USAGE_BUCKET_GENERATION.load(Ordering::Acquire) == snapshot.generation
}
#[cfg(test)]
@@ -252,10 +303,15 @@ fn dirty_usage_bucket_count() -> usize {
}
#[cfg(test)]
fn clear_dirty_usage_buckets_for_tests() {
pub(crate) fn clear_dirty_usage_buckets_for_tests() {
dirty_usage_buckets().clear();
}
#[cfg(test)]
pub(crate) fn dirty_usage_buckets_for_tests() -> DirtyUsageBuckets {
dirty_usage_buckets().clone()
}
fn bucket_usage_scan_order(
buckets: &[BucketInfo],
old_cache: &DataUsageCache,
@@ -479,6 +535,34 @@ fn finalize_nsscanner_result(results: &[DataUsageCache], first_err: Option<Error
Ok(())
}
fn classify_nsscanner_cycle(
completed_all_sets: bool,
budget_elapsed: bool,
cancelled: bool,
has_failed_buckets: bool,
dirty_usage_current: bool,
) -> ScannerCycleStatus {
if completed_all_sets && !budget_elapsed && !cancelled && !has_failed_buckets && dirty_usage_current {
ScannerCycleStatus::Complete
} else {
ScannerCycleStatus::Incomplete
}
}
fn scanner_results_have_pending_maintenance_work(results: &[DataUsageCache]) -> bool {
results.iter().any(|result| !result.info.pending_heals.is_empty())
}
fn pending_maintenance_work_for_cycle(pending: &AtomicBool, results: &[DataUsageCache]) -> bool {
pending.load(Ordering::Acquire) || scanner_results_have_pending_maintenance_work(results)
}
fn record_bucket_pending_maintenance_work(cache: &DataUsageCache, pending: &AtomicBool) {
if !cache.info.pending_heals.is_empty() {
pending.store(true, Ordering::Release);
}
}
fn is_xl_meta_path(path: &str) -> bool {
Path::new(path)
.file_name()
@@ -588,7 +672,9 @@ mod publish_gate_tests {
async fn send_cache_root_entry_info(
bucket_result_tx: &Arc<Mutex<mpsc::Sender<DataUsageEntryInfo>>>,
cache: &DataUsageCache,
pending_maintenance_work: &AtomicBool,
) -> std::result::Result<(), mpsc::error::SendError<DataUsageEntryInfo>> {
record_bucket_pending_maintenance_work(cache, pending_maintenance_work);
bucket_result_tx.lock().await.send(cache_root_entry_info(cache)).await
}
@@ -656,6 +742,18 @@ pub trait ScannerIO: Send + Sync + Debug + 'static {
) -> Result<()>;
}
#[async_trait::async_trait]
pub(crate) trait ScannerIOCycle: Send + Sync + Debug + 'static {
async fn nsscanner_with_status(
&self,
ctx: CancellationToken,
budget: Arc<ScannerCycleBudget>,
updates: mpsc::Sender<DataUsageInfo>,
want_cycle: u64,
scan_mode: HealScanMode,
) -> Result<ScannerCycleResult>;
}
#[async_trait::async_trait]
pub trait ScannerIOCache: Send + Sync + Debug + 'static {
async fn nsscanner_cache(
@@ -689,9 +787,61 @@ pub enum ScannerDiskScanOutcome {
Partial(DataUsageCache),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScannerCycleStatus {
Complete,
Incomplete,
}
#[derive(Debug)]
pub(crate) struct ScannerCycleResult {
pub(crate) status: ScannerCycleStatus,
dirty_usage_clear: Option<DirtyUsageBuckets>,
failed_dirty_usage: bool,
pending_maintenance_work: bool,
}
impl ScannerCycleResult {
pub(crate) fn new(status: ScannerCycleStatus, dirty_usage_clear: Option<DirtyUsageBuckets>) -> Self {
Self {
status,
dirty_usage_clear,
failed_dirty_usage: false,
pending_maintenance_work: false,
}
}
fn with_failed_dirty_usage(mut self, failed_dirty_usage: bool) -> Self {
self.failed_dirty_usage = failed_dirty_usage;
self
}
fn with_pending_maintenance_work(mut self, pending_maintenance_work: bool) -> Self {
self.pending_maintenance_work = pending_maintenance_work;
self
}
pub(crate) fn acknowledge_durable_usage(self) {
if let Some(snapshot) = self.dirty_usage_clear {
clear_dirty_usage_buckets(&snapshot);
}
}
pub(crate) fn has_dirty_usage_to_acknowledge(&self) -> bool {
self.dirty_usage_clear.as_ref().is_some_and(|snapshot| !snapshot.is_empty())
}
pub(crate) fn has_failed_dirty_usage(&self) -> bool {
self.failed_dirty_usage
}
pub(crate) fn has_pending_maintenance_work(&self) -> bool {
self.pending_maintenance_work
}
}
#[async_trait::async_trait]
impl ScannerIO for ECStore {
#[tracing::instrument(skip(self, budget, updates))]
async fn nsscanner(
&self,
ctx: CancellationToken,
@@ -700,13 +850,40 @@ impl ScannerIO for ECStore {
want_cycle: u64,
scan_mode: HealScanMode,
) -> Result<()> {
// Preserve the public API's pre-existing completion semantics for
// embedders. The main scanner uses nsscanner_with_status so it can
// delay this acknowledgement until usage persistence succeeds.
ScannerIOCycle::nsscanner_with_status(self, ctx, budget, updates, want_cycle, scan_mode)
.await?
.acknowledge_durable_usage();
Ok(())
}
}
#[async_trait::async_trait]
impl ScannerIOCycle for ECStore {
#[tracing::instrument(skip(self, budget, updates))]
async fn nsscanner_with_status(
&self,
ctx: CancellationToken,
budget: Arc<ScannerCycleBudget>,
updates: mpsc::Sender<DataUsageInfo>,
want_cycle: u64,
scan_mode: HealScanMode,
) -> Result<ScannerCycleResult> {
let child_token = ctx.child_token();
let dirty_generation_before_bucket_list = dirty_usage_generation();
let all_buckets = self.list_bucket(&BucketOptions::default()).await?;
let dirty_usage_snapshot = Arc::new(snapshot_dirty_usage_buckets(&all_buckets, dirty_generation_before_bucket_list));
if all_buckets.is_empty() {
reset_set_scan_gauges();
if let Err(e) = updates.send(DataUsageInfo::default()).await {
let empty_usage = DataUsageInfo {
last_update: Some(SystemTime::now()),
..Default::default()
};
if let Err(e) = updates.send(empty_usage).await {
error!(
target: "rustfs::scanner::io",
event = EVENT_SCANNER_SET_STATE,
@@ -717,7 +894,14 @@ impl ScannerIO for ECStore {
"Scanner set state update failed"
);
}
return Ok(());
let status = if dirty_usage_snapshot_covers_current(&dirty_usage_snapshot) {
ScannerCycleStatus::Complete
} else {
ScannerCycleStatus::Incomplete
};
let dirty_usage_clear =
(status == ScannerCycleStatus::Complete).then(|| dirty_usage_snapshot.buckets.as_ref().clone());
return Ok(ScannerCycleResult::new(status, dirty_usage_clear));
}
let mut total_results = 0;
@@ -735,12 +919,12 @@ impl ScannerIO for ECStore {
"Scanner set state update detected missing disk sets"
);
reset_set_scan_gauges();
return Ok(());
return Ok(ScannerCycleResult::new(ScannerCycleStatus::Incomplete, None));
}
let set_scan_limit = scanner_max_concurrent_set_scans(total_results);
let dirty_usage_buckets = Arc::new(snapshot_dirty_usage_buckets(&all_buckets));
let failed_dirty_buckets = Arc::new(Mutex::new(HashSet::<String>::new()));
let pending_maintenance_work = Arc::new(AtomicBool::new(false));
record_set_scan_concurrency_limit(set_scan_limit);
debug!(
target: "rustfs::scanner::io",
@@ -794,8 +978,12 @@ impl ScannerIO for ECStore {
});
wait_futs.push(receiver_fut);
let scan_plan =
ScannerBucketScanPlan::new(all_buckets.clone(), dirty_usage_buckets.clone(), failed_dirty_buckets.clone());
let scan_plan = ScannerBucketScanPlan::new(
all_buckets.clone(),
dirty_usage_snapshot.buckets.clone(),
failed_dirty_buckets.clone(),
pending_maintenance_work.clone(),
);
// Spawn task to run the scanner
let scanner_fut = tokio::spawn(async move {
let permit_wait = child_token_clone.clone();
@@ -870,7 +1058,7 @@ impl ScannerIO for ECStore {
let results_mutex_for_updates = results_mutex.clone();
let budget_for_updates = budget.clone();
let child_token_for_updates = child_token.clone();
let dirty_usage_buckets_for_updates = dirty_usage_buckets.clone();
let dirty_usage_snapshot_for_updates = dirty_usage_snapshot.clone();
tokio::spawn(async move {
let mut last_update = SystemTime::UNIX_EPOCH;
let mut has_sent_once = false;
@@ -893,7 +1081,7 @@ impl ScannerIO for ECStore {
&all_buckets_clone,
budget_for_updates.budget_elapsed(),
child_token_for_updates.is_cancelled(),
dirty_usage_snapshot_covers_current(dirty_usage_buckets_for_updates.as_ref()),
dirty_usage_snapshot_covers_current(dirty_usage_snapshot_for_updates.as_ref()),
)
};
@@ -912,7 +1100,7 @@ impl ScannerIO for ECStore {
&all_buckets_clone,
budget_for_updates.budget_elapsed(),
child_token_for_updates.is_cancelled(),
dirty_usage_snapshot_covers_current(dirty_usage_buckets_for_updates.as_ref()),
dirty_usage_snapshot_covers_current(dirty_usage_snapshot_for_updates.as_ref()),
)
};
@@ -954,16 +1142,27 @@ impl ScannerIO for ECStore {
let completed_all_sets = results.iter().all(|result| result.info.last_update.is_some());
let result = finalize_nsscanner_result(&results, first_err);
let failed_buckets = failed_dirty_buckets.lock().await.clone();
if let Some(clear_snapshot) = should_clear_dirty_usage_snapshot(
let pending_maintenance_work = pending_maintenance_work_for_cycle(&pending_maintenance_work, &results);
let budget_elapsed = budget.budget_elapsed();
let dirty_usage_current = dirty_usage_snapshot_covers_current(&dirty_usage_snapshot);
let cycle_status = classify_nsscanner_cycle(
completed_all_sets,
budget_elapsed,
ctx.is_cancelled(),
!failed_buckets.is_empty(),
dirty_usage_current,
);
let dirty_usage_clear = should_clear_dirty_usage_snapshot(
result.is_ok(),
completed_all_sets,
budget.budget_elapsed(),
&dirty_usage_buckets,
budget_elapsed,
&dirty_usage_snapshot.buckets,
&failed_buckets,
) {
clear_dirty_usage_buckets(&clear_snapshot);
}
result
);
result?;
Ok(ScannerCycleResult::new(cycle_status, dirty_usage_clear)
.with_failed_dirty_usage(!failed_buckets.is_empty())
.with_pending_maintenance_work(pending_maintenance_work))
}
}
@@ -983,6 +1182,7 @@ impl ScannerIOCache for SetDisks {
buckets,
dirty_usage_buckets,
failed_dirty_buckets,
pending_maintenance_work,
} = scan_plan;
let pool_label = self.pool_index.to_string();
let set_label = self.set_index.to_string();
@@ -1127,6 +1327,7 @@ impl ScannerIOCache for SetDisks {
let pool_label_clone = pool_label.clone();
let set_label_clone = set_label.clone();
let failed_dirty_buckets_clone = failed_dirty_buckets.clone();
let pending_maintenance_work_clone = pending_maintenance_work.clone();
futs.push(tokio::spawn(async move {
loop {
let Some(bucket) = bucket_rx_mutex_clone.lock().await.recv().await else {
@@ -1322,7 +1523,6 @@ impl ScannerIOCache for SetDisks {
continue;
}
};
debug!(
target: "rustfs::scanner::io",
event = EVENT_SCANNER_DISK_BUCKET_STATE,
@@ -1349,7 +1549,9 @@ impl ScannerIOCache for SetDisks {
"Scanner root entry publish started"
);
if let Err(e) = send_cache_root_entry_info(&bucket_result_tx_clone_clone, &cache).await {
if let Err(e) =
send_cache_root_entry_info(&bucket_result_tx_clone_clone, &cache, &pending_maintenance_work_clone).await
{
record_failed_dirty_bucket(&failed_dirty_buckets_clone, &bucket.name).await;
error!(
target: "rustfs::scanner::io",
@@ -1761,10 +1963,10 @@ mod tests {
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket("photos");
let buckets = vec![bucket_info("photos")];
let snapshot = snapshot_dirty_usage_buckets(&buckets);
let snapshot = snapshot_dirty_usage_buckets(&buckets, dirty_usage_generation());
record_dirty_usage_bucket("photos");
clear_dirty_usage_buckets(&snapshot);
clear_dirty_usage_buckets(&snapshot.buckets);
assert_eq!(dirty_usage_bucket_count(), 1);
clear_dirty_usage_buckets_for_tests();
@@ -1776,7 +1978,7 @@ mod tests {
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket("photos");
let buckets = vec![bucket_info("photos")];
let snapshot = snapshot_dirty_usage_buckets(&buckets);
let snapshot = snapshot_dirty_usage_buckets(&buckets, dirty_usage_generation());
assert!(dirty_usage_snapshot_covers_current(&snapshot));
@@ -1786,6 +1988,83 @@ mod tests {
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn dirty_usage_snapshot_clears_a_stably_absent_bucket_after_durable_save() {
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket("photos");
record_dirty_usage_bucket("temporarily-omitted");
let generation_before_bucket_list = dirty_usage_generation();
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], generation_before_bucket_list);
assert!(snapshot.buckets.contains_key("photos"));
assert!(snapshot.buckets.contains_key("temporarily-omitted"));
assert!(dirty_usage_buckets().contains_key("temporarily-omitted"));
assert!(dirty_usage_snapshot_covers_current(&snapshot));
ScannerCycleResult::new(ScannerCycleStatus::Complete, Some(snapshot.buckets.as_ref().clone()))
.acknowledge_durable_usage();
assert!(!dirty_usage_buckets().contains_key("temporarily-omitted"));
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn dirty_usage_snapshot_preserves_an_absent_bucket_recorded_after_listing_started() {
clear_dirty_usage_buckets_for_tests();
let generation_before_bucket_list = dirty_usage_generation();
record_dirty_usage_bucket("new-or-racing-bucket");
let snapshot = snapshot_dirty_usage_buckets(&[], generation_before_bucket_list);
assert!(!snapshot.buckets.contains_key("new-or-racing-bucket"));
assert!(!dirty_usage_snapshot_covers_current(&snapshot));
assert!(dirty_usage_buckets().contains_key("new-or-racing-bucket"));
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn deleting_a_clean_bucket_invalidates_an_inflight_usage_snapshot() {
clear_dirty_usage_buckets_for_tests();
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
assert!(dirty_usage_snapshot_covers_current(&snapshot));
record_dirty_usage_bucket("photos");
assert!(!dirty_usage_snapshot_covers_current(&snapshot));
assert!(dirty_usage_buckets().contains_key("photos"));
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn deleting_a_bucket_during_listing_invalidates_the_resulting_usage_snapshot() {
clear_dirty_usage_buckets_for_tests();
let generation_before_bucket_list = dirty_usage_generation();
record_dirty_usage_bucket("photos");
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], generation_before_bucket_list);
assert!(!dirty_usage_snapshot_covers_current(&snapshot));
assert!(dirty_usage_buckets().contains_key("photos"));
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn scanner_maintenance_change_advances_generation_and_marks_usage_dirty() {
clear_dirty_usage_buckets_for_tests();
let generation = scanner_maintenance_generation();
record_scanner_maintenance_change("photos");
assert!(scanner_maintenance_generation() > generation);
assert!(dirty_usage_buckets().contains_key("photos"));
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn dirty_usage_clear_excludes_failed_buckets() {
@@ -1793,9 +2072,9 @@ mod tests {
record_dirty_usage_bucket("photos");
record_dirty_usage_bucket("videos");
let buckets = vec![bucket_info("photos"), bucket_info("videos")];
let snapshot = snapshot_dirty_usage_buckets(&buckets);
let snapshot = snapshot_dirty_usage_buckets(&buckets, dirty_usage_generation());
let failed_buckets = HashSet::from(["videos".to_string()]);
let clear_snapshot = dirty_usage_buckets_excluding_failed(&snapshot, &failed_buckets);
let clear_snapshot = dirty_usage_buckets_excluding_failed(&snapshot.buckets, &failed_buckets);
clear_dirty_usage_buckets(&clear_snapshot);
@@ -1818,6 +2097,23 @@ mod tests {
assert!(!clear_snapshot.contains_key("videos"));
}
#[test]
#[serial]
fn dirty_usage_is_acknowledged_only_after_durable_usage_confirmation() {
clear_dirty_usage_buckets_for_tests();
record_dirty_usage_bucket("photos");
let snapshot = snapshot_dirty_usage_buckets(&[bucket_info("photos")], dirty_usage_generation());
let unconfirmed = ScannerCycleResult::new(ScannerCycleStatus::Complete, Some(snapshot.buckets.as_ref().clone()));
drop(unconfirmed);
assert!(dirty_usage_buckets().contains_key("photos"));
let confirmed = ScannerCycleResult::new(ScannerCycleStatus::Complete, Some(snapshot.buckets.as_ref().clone()));
confirmed.acknowledge_durable_usage();
assert!(!dirty_usage_buckets().contains_key("photos"));
clear_dirty_usage_buckets_for_tests();
}
#[test]
#[serial]
fn clear_dirty_usage_bucket_removes_deleted_bucket_marker() {
@@ -1828,9 +2124,9 @@ mod tests {
clear_dirty_usage_bucket("photos");
let buckets = vec![bucket_info("photos"), bucket_info("videos")];
let snapshot = snapshot_dirty_usage_buckets(&buckets);
assert!(!snapshot.contains_key("photos"));
assert!(snapshot.contains_key("videos"));
let snapshot = snapshot_dirty_usage_buckets(&buckets, dirty_usage_generation());
assert!(!snapshot.buckets.contains_key("photos"));
assert!(snapshot.buckets.contains_key("videos"));
assert_eq!(dirty_usage_bucket_count(), 1);
clear_dirty_usage_buckets_for_tests();
}
@@ -1896,6 +2192,78 @@ mod tests {
assert!(err.to_string().contains("set failed"));
}
#[test]
fn scanner_cycle_status_requires_a_clean_complete_snapshot() {
assert_eq!(classify_nsscanner_cycle(true, false, false, false, true), ScannerCycleStatus::Complete);
for status in [
classify_nsscanner_cycle(false, false, false, false, true),
classify_nsscanner_cycle(true, true, false, false, true),
classify_nsscanner_cycle(true, false, true, false, true),
classify_nsscanner_cycle(true, false, false, true, true),
classify_nsscanner_cycle(true, false, false, false, false),
] {
assert_eq!(status, ScannerCycleStatus::Incomplete);
}
}
#[test]
fn scanner_cycle_surfaces_persisted_pending_heal_work() {
let clean = DataUsageCache::default();
assert!(!scanner_results_have_pending_maintenance_work(std::slice::from_ref(&clean)));
let mut pending = clean;
pending.info.pending_heals.push(crate::PendingScannerHeal {
kind: crate::PendingScannerHealKind::Object,
bucket: "photos".to_string(),
object: Some("image.jpg".to_string()),
version_id: None,
scan_mode: HealScanMode::Normal,
first_seen: 1,
last_attempt: 1,
attempts: 1,
last_admission_result: "queue_full".to_string(),
last_admission_reason: "capacity".to_string(),
});
assert!(scanner_results_have_pending_maintenance_work(&[pending]));
}
#[tokio::test]
async fn bucket_cache_pending_heal_reaches_cycle_maintenance_state() {
let pending_maintenance_work = Arc::new(AtomicBool::new(false));
let mut bucket_cache = DataUsageCache::default();
bucket_cache.info.pending_heals.push(crate::PendingScannerHeal {
kind: crate::PendingScannerHealKind::Object,
bucket: "photos".to_string(),
object: Some("image.jpg".to_string()),
version_id: None,
scan_mode: HealScanMode::Normal,
first_seen: 1,
last_attempt: 1,
attempts: 1,
last_admission_result: "queue_full".to_string(),
last_admission_reason: "capacity".to_string(),
});
let (sender, mut receiver) = mpsc::channel(1);
let sender = Arc::new(Mutex::new(sender));
send_cache_root_entry_info(&sender, &bucket_cache, &pending_maintenance_work)
.await
.expect("bucket result should send");
let cycle_pending = pending_maintenance_work_for_cycle(&pending_maintenance_work, &[]);
assert!(cycle_pending);
assert_eq!(
crate::scanner::scanner_cycle_outcome_with_pending_maintenance(
crate::scanner::ScannerCycleOutcome::Completed,
cycle_pending,
),
crate::scanner::ScannerCycleOutcome::CompletedWithPendingMaintenance
);
assert!(receiver.recv().await.is_some());
}
#[test]
#[serial]
fn scanner_concurrency_limit_preserves_available_when_unconfigured() {
+39 -1
View File
@@ -70,6 +70,38 @@ sleep multiplier, maximum wait, and cycle interval. Use `scanner.delay`,
`scanner.max_wait`, and `scanner.cycle` when the preset is close but one axis
needs a precise override.
## Single-disk clean-idle scheduling
An erasure single-disk deployment using the built-in cycle and bitrot defaults
automatically backs off repeated clean idle scans instead of walking the same
unchanged namespace every minute. Each successful timer-driven cycle that
finds no dirty usage or unresolved maintenance work doubles the next interval.
The status endpoint reports the effective interval and multiplier.
The backoff is reset to the base interval by object or bucket mutations,
lifecycle or replication configuration changes, partial or failed cycles,
usage persistence failures, and unresolved scanner-originated heal or bitrot
work. Active lifecycle or replication rules keep the base cadence. An explicit
cycle, a non-default persisted speed, any environment speed or start-delay
override, an environment bitrot override, or a non-default persisted active
bitrot cycle also keeps the configured cadence rather than applying the
automatic policy. Persisting `scanner.speed=default` or the default bitrot cycle
is normalized to the built-in default and therefore keeps automatic scheduling
enabled.
Lifecycle and replication configuration inspection is bounded so a slow
metadata read cannot stall scanner startup or scheduling. A failed or timed-out
inspection keeps the base cadence and is retried after 5 minutes, doubling up
to a maximum of 60 minutes while failures continue. A lifecycle or replication
configuration change wakes the scanner and retries inspection immediately.
With the default 30-day bitrot cycle, the clean-idle interval is capped at the
bitrot cycle divided by the object selection window. With the default selection
window this is about 42 minutes, which preserves the intended wall-clock bitrot
coverage. If periodic bitrot is disabled, the clean-idle policy cap is 24 hours.
The effective interval is jittered by up to 10 percent to avoid synchronized
scanner starts.
## Status Endpoint
The scanner status route is:
@@ -79,9 +111,12 @@ GET /v3/scanner/status
```
The request must be authenticated with an admin identity that has
`ServerInfoAdminAction`. The JSON response has two top-level objects:
`ServerInfoAdminAction`. The JSON response has three scanner-specific top-level
objects:
- `runtime_config`: the effective runtime controls and their value sources.
- `cycle_schedule`: the current effective cycle interval and clean-idle
backoff state.
- `metrics`: scanner work, pressure, checkpoint, lifecycle, replication, heal,
bitrot, and alert counters.
@@ -93,6 +128,9 @@ runtime_config.delay.value
runtime_config.max_wait_seconds.value
runtime_config.cycle_interval_seconds.value
runtime_config.bitrot_cycle_seconds.value
cycle_schedule.effective_interval_seconds
cycle_schedule.clean_idle_backoff_enabled
cycle_schedule.clean_idle_backoff_multiplier
metrics.pacing_pressure.primary_pressure
metrics.pacing_pressure.last_cycle_budget_limited
metrics.lifecycle_transition.current_queued
+59 -12
View File
@@ -37,6 +37,7 @@ struct ScannerStatusResponse {
disabled_reason: Option<String>,
freshness: ScannerFreshnessStatus,
metrics: ScannerMetricsReport,
cycle_schedule: rustfs_scanner::ScannerCycleScheduleStatus,
runtime_config: rustfs_scanner::runtime_config::ScannerRuntimeConfigStatus,
}
@@ -55,13 +56,15 @@ fn scanner_disabled_reason(enabled: bool) -> Option<String> {
fn scanner_freshness_status(
metrics: &ScannerMetricsReport,
runtime_config: &rustfs_scanner::runtime_config::ScannerRuntimeConfigStatus,
effective_cycle_interval_seconds: u64,
) -> ScannerFreshnessStatus {
const FRESHNESS_MULTIPLIER: u64 = 2;
let max_expected_age_seconds = runtime_config
let expected_cycle_interval_seconds = runtime_config
.cycle_interval_seconds
.value
.saturating_mul(FRESHNESS_MULTIPLIER);
.max(effective_cycle_interval_seconds);
let max_expected_age_seconds = expected_cycle_interval_seconds.saturating_mul(FRESHNESS_MULTIPLIER);
if metrics.last_cycle_end_unix_secs == 0 {
return ScannerFreshnessStatus {
state: "unknown",
@@ -90,6 +93,23 @@ fn scanner_freshness_status(
}
}
fn scanner_status_response(
enabled: bool,
metrics: ScannerMetricsReport,
runtime_config: rustfs_scanner::runtime_config::ScannerRuntimeConfigStatus,
cycle_schedule: rustfs_scanner::ScannerCycleScheduleStatus,
) -> ScannerStatusResponse {
let freshness = scanner_freshness_status(&metrics, &runtime_config, cycle_schedule.effective_interval_seconds());
ScannerStatusResponse {
enabled,
disabled_reason: scanner_disabled_reason(enabled),
freshness,
metrics,
cycle_schedule,
runtime_config,
}
}
pub fn register_scanner_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
r.insert(
Method::GET,
@@ -142,14 +162,8 @@ impl Operation for ScannerStatusHandler {
let enabled = scanner_enabled_from_env();
let metrics = current_scanner_metrics_report().await;
let runtime_config = rustfs_scanner::scanner_runtime_config_status();
let freshness = scanner_freshness_status(&metrics, &runtime_config);
let response = ScannerStatusResponse {
enabled,
disabled_reason: scanner_disabled_reason(enabled),
freshness,
metrics,
runtime_config,
};
let cycle_schedule = rustfs_scanner::scanner_cycle_schedule_status();
let response = scanner_status_response(enabled, metrics, runtime_config, cycle_schedule);
let body = serde_json::to_vec(&response).map_err(|err| {
S3Error::with_message(S3ErrorCode::InternalError, format!("failed to encode scanner status: {err}"))
})?;
@@ -174,7 +188,7 @@ mod tests {
let mut runtime_config = rustfs_scanner::scanner_runtime_config_status();
runtime_config.cycle_interval_seconds.value = 60;
let freshness = scanner_freshness_status(&metrics, &runtime_config);
let freshness = scanner_freshness_status(&metrics, &runtime_config, 0);
assert_eq!(freshness.state, "unknown");
assert_eq!(freshness.last_cycle_end_unix_secs, 0);
@@ -191,10 +205,43 @@ mod tests {
let mut runtime_config = rustfs_scanner::scanner_runtime_config_status();
runtime_config.cycle_interval_seconds.value = 60;
let freshness = scanner_freshness_status(&metrics, &runtime_config);
let freshness = scanner_freshness_status(&metrics, &runtime_config, 0);
assert_eq!(freshness.state, "stale");
assert_eq!(freshness.max_expected_age_seconds, 120);
assert_eq!(freshness.reason, Some("last cycle is older than freshness window"));
}
#[test]
fn scanner_freshness_uses_effective_clean_idle_interval() {
let metrics = ScannerMetricsReport {
last_cycle_end_unix_secs: u64::try_from(Utc::now().timestamp().max(0))
.expect("non-negative timestamp should fit in u64")
.saturating_sub(300),
..Default::default()
};
let mut runtime_config = rustfs_scanner::scanner_runtime_config_status();
runtime_config.cycle_interval_seconds.value = 60;
let freshness = scanner_freshness_status(&metrics, &runtime_config, 3_600);
assert_eq!(freshness.state, "fresh");
assert_eq!(freshness.max_expected_age_seconds, 7_200);
assert_eq!(freshness.reason, None);
}
#[test]
fn scanner_status_serializes_cycle_schedule_contract() {
let response = scanner_status_response(
true,
ScannerMetricsReport::default(),
rustfs_scanner::scanner_runtime_config_status(),
rustfs_scanner::ScannerCycleScheduleStatus::default(),
);
let encoded = serde_json::to_value(response).expect("scanner status should serialize");
assert_eq!(encoded["cycle_schedule"]["effective_interval_seconds"], 0);
assert_eq!(encoded["cycle_schedule"]["clean_idle_backoff_enabled"], false);
assert_eq!(encoded["cycle_schedule"]["clean_idle_backoff_multiplier"], 1);
}
}
+2 -2
View File
@@ -245,11 +245,11 @@ pub(crate) mod metadata_sys {
}
pub(crate) async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
super::ecstore_bucket::metadata_sys::update(bucket, config_file, data).await
crate::storage::storage_api::update_bucket_metadata_config(bucket, config_file, data).await
}
pub(crate) async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
super::ecstore_bucket::metadata_sys::delete(bucket, config_file).await
crate::storage::storage_api::delete_bucket_metadata_config(bucket, config_file).await
}
pub(crate) async fn get_bucket_policy(bucket: &str) -> Result<(BucketPolicy, OffsetDateTime)> {
+75 -42
View File
@@ -485,16 +485,29 @@ fn notify_bucket_metadata_reload(
bucket: String,
operation: &'static str,
request_context: Option<request_context::RequestContext>,
scanner_maintenance_change: bool,
) {
record_local_scanner_maintenance_reload(&bucket, scanner_maintenance_change);
spawn_background_with_context(request_context, async move {
if let Some(notification_sys) = current_notification_system()
&& let Err(err) = notification_sys.load_bucket_metadata(&bucket).await
{
warn!(bucket = %bucket, error = %err, "failed to notify peers after {operation}");
if let Some(notification_sys) = current_notification_system() {
let result = if scanner_maintenance_change {
notification_sys.load_bucket_metadata_for_scanner_maintenance(&bucket).await
} else {
notification_sys.load_bucket_metadata(&bucket).await
};
if let Err(err) = result {
warn!(bucket = %bucket, error = %err, "failed to notify peers after {operation}");
}
}
});
}
fn record_local_scanner_maintenance_reload(bucket: &str, scanner_maintenance_change: bool) {
if scanner_maintenance_change {
rustfs_scanner::record_scanner_maintenance_change(bucket);
}
}
/// Notify peers to drop their cached metadata for a bucket that was just deleted,
/// so they stop serving stale bucket configuration. Runs in the background to
/// avoid blocking the delete response.
@@ -1205,7 +1218,9 @@ impl DefaultBucketUsecase {
// Invalidate bucket validation cache
crate::storage::invalidate_bucket_validation_cache(&input.bucket);
rustfs_scanner::clear_dirty_usage_bucket(&input.bucket);
// Re-evaluate lifecycle/replication after bucket removal and keep an
// absent-bucket dirty marker until a complete usage snapshot is durable.
rustfs_scanner::record_scanner_maintenance_change(&input.bucket);
if let Err(err) = remove_bucket_usage_from_backend(store.clone(), &input.bucket).await {
warn!(bucket = %input.bucket, error = ?err, "failed to remove deleted bucket from data usage");
}
@@ -1338,7 +1353,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket encryption", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket encryption", request_context, false);
let item = sr_bucket_meta_item(bucket.clone(), "sse-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
@@ -1369,7 +1384,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket cors", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket cors", request_context, false);
let item = sr_bucket_meta_item(bucket.clone(), "cors-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
@@ -1400,14 +1415,13 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context, true);
let item = sr_bucket_meta_item(bucket.clone(), "lc-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle delete hook failed");
}
rustfs_scanner::record_dirty_usage_bucket(&bucket);
Ok(S3Response::new(DeleteBucketLifecycleOutput::default()))
}
@@ -1431,7 +1445,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket policy", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket policy", request_context, false);
let item = sr_bucket_meta_item(bucket.clone(), "policy");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
@@ -1481,7 +1495,7 @@ impl DefaultBucketUsecase {
}
drop(targets_guard);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context, true);
let item = sr_bucket_meta_item(bucket.clone(), "replication-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
@@ -1490,7 +1504,6 @@ impl DefaultBucketUsecase {
info!(bucket = %bucket, "deleted bucket replication config");
rustfs_scanner::record_dirty_usage_bucket(&bucket);
Ok(S3Response::new(DeleteBucketReplicationOutput::default()))
}
@@ -1506,7 +1519,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket tagging", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket tagging", request_context, false);
let item = sr_bucket_meta_item(bucket.clone(), "tags");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
@@ -1538,7 +1551,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete public access block", request_context);
notify_bucket_metadata_reload(bucket.clone(), "delete public access block", request_context, false);
Ok(S3Response::with_status(DeletePublicAccessBlockOutput::default(), StatusCode::NO_CONTENT))
}
@@ -1990,7 +2003,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket encryption", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket encryption", request_context, false);
let mut item = sr_bucket_meta_item(bucket.clone(), "sse-config");
item.sse_config = Some(
@@ -2051,7 +2064,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context, true);
let mut item = sr_bucket_meta_item(bucket.clone(), "lc-config");
item.expiry_lc_config =
@@ -2096,7 +2109,6 @@ impl DefaultBucketUsecase {
});
}
rustfs_scanner::record_dirty_usage_bucket(&bucket);
Ok(S3Response::new(PutBucketLifecycleConfigurationOutput::default()))
}
@@ -2129,7 +2141,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket notification", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket notification", request_context, false);
let region = resolve_notification_region(self.global_region(), request_region);
let notify = current_notify_interface_for_context(self.context.as_deref());
@@ -2232,7 +2244,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket policy", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket policy", request_context, false);
let mut item = sr_bucket_meta_item(bucket.clone(), "policy");
item.policy = Some(serde_json::from_str(&policy).map_err(|e| s3_error!(InvalidArgument, "parse policy failed {:?}", e))?);
@@ -2266,7 +2278,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket cors", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket cors", request_context, false);
let mut item = sr_bucket_meta_item(bucket.clone(), "cors-config");
item.cors =
@@ -2307,7 +2319,7 @@ impl DefaultBucketUsecase {
.map_err(ApiError::from)?;
drop(targets_guard);
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context, true);
let mut item = sr_bucket_meta_item(bucket.clone(), "replication-config");
item.replication_config = Some(
@@ -2317,7 +2329,6 @@ impl DefaultBucketUsecase {
warn!(bucket = %bucket, error = ?err, "site replication bucket replication-config hook failed");
}
rustfs_scanner::record_dirty_usage_bucket(&bucket);
Ok(S3Response::new(PutBucketReplicationOutput::default()))
}
@@ -2347,7 +2358,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put public access block", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put public access block", request_context, false);
Ok(S3Response::new(PutPublicAccessBlockOutput::default()))
}
@@ -2375,7 +2386,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket tagging", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket tagging", request_context, false);
let mut item = sr_bucket_meta_item(bucket.clone(), "tags");
item.tags = Some(serialize_config(&tagging).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
@@ -2407,7 +2418,7 @@ impl DefaultBucketUsecase {
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket versioning", request_context);
notify_bucket_metadata_reload(bucket.clone(), "put bucket versioning", request_context, false);
let mut item = sr_bucket_meta_item(bucket.clone(), "version-config");
item.versioning = Some(
@@ -2654,23 +2665,23 @@ mod tests {
#[test]
fn bucket_metadata_config_changes_notify_peer_metadata_reload() {
let source = include_str!("bucket_usecase.rs");
for (method, operation) in [
("execute_delete_bucket_policy", "delete bucket policy"),
("execute_put_bucket_policy", "put bucket policy"),
("execute_delete_public_access_block", "delete public access block"),
("execute_put_public_access_block", "put public access block"),
("execute_delete_bucket_lifecycle", "delete bucket lifecycle"),
("execute_put_bucket_lifecycle_configuration", "put bucket lifecycle"),
("execute_put_bucket_versioning", "put bucket versioning"),
("execute_delete_bucket_tagging", "delete bucket tagging"),
("execute_put_bucket_tagging", "put bucket tagging"),
("execute_delete_bucket_replication", "delete bucket replication"),
("execute_put_bucket_replication", "put bucket replication"),
("execute_delete_bucket_cors", "delete bucket cors"),
("execute_put_bucket_cors", "put bucket cors"),
("execute_delete_bucket_encryption", "delete bucket encryption"),
("execute_put_bucket_encryption", "put bucket encryption"),
("execute_put_bucket_notification_configuration", "put bucket notification"),
for (method, operation, scanner_maintenance_change) in [
("execute_delete_bucket_policy", "delete bucket policy", false),
("execute_put_bucket_policy", "put bucket policy", false),
("execute_delete_public_access_block", "delete public access block", false),
("execute_put_public_access_block", "put public access block", false),
("execute_delete_bucket_lifecycle", "delete bucket lifecycle", true),
("execute_put_bucket_lifecycle_configuration", "put bucket lifecycle", true),
("execute_put_bucket_versioning", "put bucket versioning", false),
("execute_delete_bucket_tagging", "delete bucket tagging", false),
("execute_put_bucket_tagging", "put bucket tagging", false),
("execute_delete_bucket_replication", "delete bucket replication", true),
("execute_put_bucket_replication", "put bucket replication", true),
("execute_delete_bucket_cors", "delete bucket cors", false),
("execute_put_bucket_cors", "put bucket cors", false),
("execute_delete_bucket_encryption", "delete bucket encryption", false),
("execute_put_bucket_encryption", "put bucket encryption", false),
("execute_put_bucket_notification_configuration", "put bucket notification", false),
] {
let body = usecase_method_source(source, method);
assert!(
@@ -2681,9 +2692,31 @@ mod tests {
body.contains(operation),
"{method} should identify the bucket metadata operation in reload logs"
);
let expected_reload = format!(
"notify_bucket_metadata_reload(bucket.clone(), \"{operation}\", request_context, {scanner_maintenance_change});"
);
assert!(
body.contains(&expected_reload),
"{method} should propagate scanner_maintenance_change={scanner_maintenance_change}"
);
}
}
#[test]
#[serial_test::serial]
fn scanner_maintenance_reload_marks_only_scanner_owned_config_changes() {
const BUCKET: &str = "scanner-maintenance-reload-test";
rustfs_scanner::clear_dirty_usage_bucket(BUCKET);
let before = rustfs_scanner::scanner_maintenance_generation();
record_local_scanner_maintenance_reload(BUCKET, false);
assert_eq!(rustfs_scanner::scanner_maintenance_generation(), before);
record_local_scanner_maintenance_reload(BUCKET, true);
assert_eq!(rustfs_scanner::scanner_maintenance_generation(), before.saturating_add(1));
rustfs_scanner::clear_dirty_usage_bucket(BUCKET);
}
fn replication_rule_for_target(arn: &str) -> ReplicationRule {
ReplicationRule {
delete_marker_replication: None,
+2 -2
View File
@@ -473,7 +473,7 @@ pub(crate) mod bucket {
bucket: &str,
config_file: &str,
) -> Result<OffsetDateTime, crate::storage::storage_api::StorageError> {
crate::storage::storage_api::ecstore_bucket::metadata_sys::delete(bucket, config_file).await
crate::storage::storage_api::delete_bucket_metadata_config(bucket, config_file).await
}
pub(crate) async fn get_bucket_policy(
@@ -551,7 +551,7 @@ pub(crate) mod bucket {
config_file: &str,
data: Vec<u8>,
) -> Result<OffsetDateTime, crate::storage::storage_api::StorageError> {
crate::storage::storage_api::ecstore_bucket::metadata_sys::update(bucket, config_file, data).await
crate::storage::storage_api::update_bucket_metadata_config(bucket, config_file, data).await
}
}
+55 -5
View File
@@ -55,6 +55,14 @@ const EVENT_RPC_RESPONSE_EMITTED: &str = "rpc_response_emitted";
const EVENT_RPC_BACKGROUND_TASK_SPAWNED: &str = "rpc_background_task_spawned";
const EVENT_RPC_BACKGROUND_TASK_FAILED: &str = "rpc_background_task_failed";
fn scanner_activity_response(namespace_generation: u64) -> ScannerActivityResponse {
ScannerActivityResponse {
instance_id: rustfs_scanner::scanner_activity_epoch().to_string(),
namespace_generation,
maintenance_generation: rustfs_scanner::scanner_maintenance_generation(),
}
}
macro_rules! log_load_rebalance_meta_rejected {
($reason:expr, $start_rebalance:expr) => {
warn!(
@@ -980,6 +988,16 @@ impl Node for NodeService {
}
}
async fn scanner_activity(
&self,
_request: Request<ScannerActivityRequest>,
) -> Result<Response<ScannerActivityResponse>, Status> {
let store = self
.resolve_object_store()
.ok_or_else(|| Status::unavailable("storage layer is not initialized"))?;
Ok(Response::new(scanner_activity_response(store.scanner_namespace_mutation_generation())))
}
async fn background_heal_status(
&self,
_request: Request<BackgroundHealStatusRequest>,
@@ -1216,7 +1234,7 @@ mod tests {
use super::{
CollectMetricsOpts, Error, MetricType, Node as _, NodeService, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, STORAGE_CLASS_SUB_SYS,
background_rebalance_start_error_message, make_server, stop_rebalance_response,
background_rebalance_start_error_message, make_server, scanner_activity_response, stop_rebalance_response,
};
use bytes::Bytes;
use rustfs_protos::models::PingBodyBuilder;
@@ -1232,9 +1250,10 @@ mod tests {
LoadTransitionTierConfigRequest, LoadUserRequest, LocalStorageInfoRequest, MakeBucketRequest, MakeVolumeRequest,
MakeVolumesRequest, Mss, PingRequest, ReadAllRequest, ReadAtRequest, ReadMultipleRequest, ReadVersionRequest,
ReadXlRequest, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, RenameDataRequest, RenameFileRequest,
RenamePartRequest, ServerInfoRequest, SignalServiceRequest, StartProfilingRequest, StatVolumeRequest,
StopRebalanceRequest, UpdateMetacacheListingRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest,
WriteMetadataRequest, WriteRequest, node_service_client::NodeServiceClient, node_service_server::NodeServiceServer,
RenamePartRequest, ScannerActivityRequest, ServerInfoRequest, SignalServiceRequest, StartProfilingRequest,
StatVolumeRequest, StopRebalanceRequest, UpdateMetacacheListingRequest, UpdateMetadataRequest, VerifyFileRequest,
WriteAllRequest, WriteMetadataRequest, WriteRequest, node_service_client::NodeServiceClient,
node_service_server::NodeServiceServer,
};
use std::collections::HashMap;
use tokio::net::TcpListener;
@@ -2561,8 +2580,12 @@ mod tests {
#[tokio::test]
async fn test_load_bucket_metadata_empty_bucket() {
let service = create_test_node_service();
let maintenance_generation = rustfs_scanner::scanner_maintenance_generation();
let request = Request::new(LoadBucketMetadataRequest { bucket: "".to_string() });
let request = Request::new(LoadBucketMetadataRequest {
bucket: "".to_string(),
scanner_maintenance_change: true,
});
let response = service.load_bucket_metadata(request).await;
assert!(response.is_ok());
@@ -2571,6 +2594,11 @@ mod tests {
assert!(!load_response.success);
assert!(load_response.error_info.is_some());
assert!(load_response.error_info.unwrap().contains("bucket name is missing"));
assert_eq!(
rustfs_scanner::scanner_maintenance_generation(),
maintenance_generation,
"rejected metadata reloads must not advance scanner maintenance activity"
);
}
#[tokio::test]
@@ -2580,6 +2608,7 @@ mod tests {
let request = Request::new(LoadBucketMetadataRequest {
bucket: "test-bucket".to_string(),
scanner_maintenance_change: false,
});
let response = service.load_bucket_metadata(request).await;
@@ -2829,6 +2858,27 @@ mod tests {
assert_eq!(signal_response.error_info.as_deref(), Some("unsupported service signal: 99"));
}
#[tokio::test]
async fn test_scanner_activity_requires_storage_layer() {
let service = create_test_node_service();
let err = service
.scanner_activity(Request::new(ScannerActivityRequest {}))
.await
.expect_err("activity queries must fail closed before storage is initialized");
assert_eq!(err.code(), tonic::Code::Unavailable);
}
#[test]
fn test_scanner_activity_response_uses_process_epoch_and_generations() {
let response = scanner_activity_response(17);
assert_eq!(response.instance_id, rustfs_scanner::scanner_activity_epoch());
assert_eq!(response.namespace_generation, 17);
assert_eq!(response.maintenance_generation, rustfs_scanner::scanner_maintenance_generation());
}
#[tokio::test]
async fn test_signal_service_rejects_non_dynamic_subsystem() {
let service = create_test_node_service();
@@ -57,6 +57,7 @@ impl NodeService {
request: Request<LoadBucketMetadataRequest>,
) -> Result<Response<LoadBucketMetadataResponse>, Status> {
let request = request.into_inner();
let scanner_maintenance_change = request.scanner_maintenance_change;
let bucket = request.bucket;
if bucket.is_empty() {
return Ok(Response::new(LoadBucketMetadataResponse {
@@ -74,12 +75,15 @@ impl NodeService {
match load_bucket_metadata(store, &bucket).await {
Ok(meta) => {
if let Err(err) = set_bucket_metadata(bucket, meta).await {
if let Err(err) = set_bucket_metadata(bucket.clone(), meta).await {
return Ok(Response::new(LoadBucketMetadataResponse {
success: false,
error_info: Some(err.to_string()),
}));
};
if scanner_maintenance_change {
rustfs_scanner::record_scanner_maintenance_change(&bucket);
}
Ok(Response::new(LoadBucketMetadataResponse {
success: true,
error_info: None,
+29 -3
View File
@@ -1169,7 +1169,9 @@ pub(crate) fn get_global_bucket_metadata_sys() -> Option<Arc<tokio::sync::RwLock
}
pub(crate) async fn delete_bucket_metadata_config(bucket: &str, config_file: &str) -> Result<time::OffsetDateTime> {
ecstore_bucket::metadata_sys::delete(bucket, config_file).await
let updated_at = ecstore_bucket::metadata_sys::delete(bucket, config_file).await?;
record_scanner_maintenance_config_change(bucket, config_file);
Ok(updated_at)
}
pub(crate) async fn get_bucket_metadata(bucket: &str) -> Result<Arc<BucketMetadata>> {
@@ -1241,7 +1243,22 @@ pub(crate) async fn update_bucket_metadata_config(
config_file: &str,
data: Vec<u8>,
) -> Result<time::OffsetDateTime> {
ecstore_bucket::metadata_sys::update(bucket, config_file, data).await
let updated_at = ecstore_bucket::metadata_sys::update(bucket, config_file, data).await?;
record_scanner_maintenance_config_change(bucket, config_file);
Ok(updated_at)
}
fn record_scanner_maintenance_config_change(bucket: &str, config_file: &str) {
if scanner_maintenance_config_file(config_file) {
rustfs_scanner::record_scanner_maintenance_change(bucket);
}
}
fn scanner_maintenance_config_file(config_file: &str) -> bool {
matches!(
config_file,
ecstore_bucket::metadata::BUCKET_LIFECYCLE_CONFIG | ecstore_bucket::metadata::BUCKET_REPLICATION_CONFIG
)
}
pub(crate) fn add_object_lock_years(dt: time::OffsetDateTime, years: i32) -> time::OffsetDateTime {
@@ -1465,7 +1482,9 @@ pub(crate) async fn init_compression_total_memory_from_backend(store: Arc<ECStor
#[cfg(test)]
mod tests {
use super::{bucket_targets_metadata_lock_shard, lock_bucket_targets_metadata};
use super::{
bucket_targets_metadata_lock_shard, ecstore_bucket, lock_bucket_targets_metadata, scanner_maintenance_config_file,
};
use std::time::Duration;
#[tokio::test]
@@ -1494,4 +1513,11 @@ mod tests {
.is_ok()
);
}
#[test]
fn scanner_maintenance_config_only_includes_scanner_owned_work() {
assert!(scanner_maintenance_config_file(ecstore_bucket::metadata::BUCKET_LIFECYCLE_CONFIG));
assert!(scanner_maintenance_config_file(ecstore_bucket::metadata::BUCKET_REPLICATION_CONFIG));
assert!(!scanner_maintenance_config_file(ecstore_bucket::metadata::BUCKET_POLICY_CONFIG));
}
}