Files
rustfs/crates/scanner/src/scanner_io/publish_gate_tests.rs
T
houseme 769e66511f refactor(scanner): split scanner_io.rs into child modules (#6294)
Split the 5369-line scanner_io.rs (39% inline tests) into a canonical
scanner_io.rs + scanner_io/ module tree with zero behavior change:

- scanner_io.rs (~660): constants, metadata-error constructors, the
  bucket scan plan, cycle-status classification helpers, the ScannerIO /
  ScannerIOCache / ScannerIODisk traits, and ScannerCycleResult
- scanner_io/dirty_usage.rs (~300): process-wide dirty-usage statics
  and the acknowledgment protocol
- scanner_io/guards.rs (~270): concurrency gauges and RAII guards
- scanner_io/cache.rs (~410): scanner cache locks and the snapshot
  persist/publish path
- scanner_io/io_cycle.rs (~390), io_cache.rs (~1160), io_disk.rs
  (~230): the ECStore / SetDisks / Disk trait implementations
- scanner_io/publish_gate_tests.rs (~750) and tests.rs (~1340): the two
  inline test modules as child modules

All crate paths are unchanged: the lib.rs scanner_io re-exports and
every crate::scanner_io:: consumer (scanner.rs, remote_scanner,
scanner_folder, and cross-crate rustfs users) resolve through root
re-exports with their original visibilities (pub stays pub, pub(crate)
stays pub(crate)). Cross-module items gain pub(super), whose scope
equals the old single-module privacy domain. Code is moved verbatim
apart from those markers, per-module import headers, and rustfmt
re-wraps.

The logging-guardrail nsscanner_disk skip-set_disks rule now points at
scanner_io/io_disk.rs where the function moved; the pattern and
thresholds are unchanged.

Co-authored-by: heihutu <heihutu@gmail.com>
2026-08-20 12:18:45 +08:00

752 lines
28 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
use rustfs_data_usage::{ReplicationAllStats, ReplicationStats};
const TEST_PLAN_DIGEST: DataUsageScanPlanDigest = DataUsageScanPlanDigest([7; 32]);
#[test]
fn should_publish_completed_snapshot_requires_full_clean_cycle() {
assert!(should_publish_completed_snapshot(3, 3, false, false));
assert!(!should_publish_completed_snapshot(2, 3, false, false));
assert!(!should_publish_completed_snapshot(3, 3, true, false));
assert!(!should_publish_completed_snapshot(3, 3, false, true));
assert!(
should_publish_completed_snapshot(0, 0, false, false),
"a completed empty namespace is an authoritative zero snapshot"
);
}
fn incomplete_scope_cache(source: DataUsageCacheSource) -> DataUsageCache {
DataUsageCache {
info: DataUsageCacheInfo {
name: DATA_USAGE_ROOT.to_string(),
source: Some(source),
snapshot_complete: false,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
..Default::default()
}
}
#[test]
fn incomplete_scan_scope_requires_every_expected_set_marker() {
let first_source = DataUsageCacheSource::new(0, 0);
let second_source = DataUsageCacheSource::new(1, 0);
let expected_sources = HashSet::from([first_source, second_source]);
let first = incomplete_scope_cache(first_source);
let second = incomplete_scope_cache(second_source);
assert!(scanner_results_match_scan_scope(&[first.clone(), second], &expected_sources));
assert!(!scanner_results_form_complete_snapshot(
&[first.clone(), incomplete_scope_cache(second_source)],
&expected_sources
));
assert!(!scanner_results_match_scan_scope(
&[first.clone(), DataUsageCache::default()],
&expected_sources
));
assert!(!scanner_results_match_scan_scope(
&[first.clone(), incomplete_scope_cache(first_source)],
&expected_sources
));
let mut mismatched_plan = incomplete_scope_cache(second_source);
mismatched_plan.info.scan_plan_digest = Some(DataUsageScanPlanDigest([8; 32]));
assert!(!scanner_results_match_scan_scope(&[first, mismatched_plan], &expected_sources));
}
fn completed_root_cache(bucket: &str, objects: usize, update_secs: u64, source: DataUsageCacheSource) -> DataUsageCache {
let mut cache = DataUsageCache {
info: DataUsageCacheInfo {
name: DATA_USAGE_ROOT.to_string(),
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(update_secs)),
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
..Default::default()
};
cache.replace(
bucket,
DATA_USAGE_ROOT,
DataUsageEntry {
objects,
size: objects.saturating_mul(10),
..Default::default()
},
);
cache
}
fn completed_data_usage_info_for_test(
results: &[DataUsageCache],
all_buckets: &[String],
budget_elapsed: bool,
cancelled: bool,
) -> Option<(DataUsageInfo, SystemTime)> {
let expected_sources = results.iter().filter_map(|result| result.info.source).collect::<HashSet<_>>();
completed_data_usage_info(results, &expected_sources, all_buckets, true, budget_elapsed, cancelled)
}
#[test]
fn completed_data_usage_info_publishes_tier_stats_across_sets() {
let all_buckets = vec!["bucket-a".to_string(), "bucket-b".to_string()];
let warm = |total_size, num_versions, num_objects| {
HashMap::from([(
"WARM".to_string(),
TierStats {
total_size,
num_versions,
num_objects,
},
)])
};
let mut first_set = completed_root_cache("bucket-a", 1, 10, DataUsageCacheSource::new(0, 0));
let mut tiered = DataUsageEntry::default();
tiered.add_tier_sizes(&warm(100, 2, 1));
first_set.replace("bucket-b", DATA_USAGE_ROOT, tiered);
let mut second_set = completed_root_cache("bucket-b", 2, 20, DataUsageCacheSource::new(1, 0));
let mut tiered = DataUsageEntry::default();
tiered.add_tier_sizes(&warm(50, 1, 1));
second_set.replace("bucket-a", DATA_USAGE_ROOT, tiered);
let (data_usage_info, _) = completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false)
.expect("completed sets should publish a snapshot");
assert_eq!(
data_usage_info
.tier_stats
.expect("tier usage should reach the snapshot")
.tiers["WARM"],
TierStats {
total_size: 150,
num_versions: 3,
num_objects: 2,
}
);
}
#[test]
fn completed_data_usage_info_omits_tier_stats_without_tiered_objects() {
let all_buckets = vec!["bucket-a".to_string()];
let set = completed_root_cache("bucket-a", 1, 10, DataUsageCacheSource::new(0, 0));
let (data_usage_info, _) =
completed_data_usage_info_for_test(&[set], &all_buckets, false, false).expect("completed set should publish a snapshot");
assert!(data_usage_info.tier_stats.is_none());
}
#[test]
fn completed_data_usage_info_requires_every_set_before_publish() {
let all_buckets = vec!["bucket-a".to_string(), "bucket-b".to_string(), "bucket-empty".to_string()];
let mut first_set = completed_root_cache("bucket-a", 1, 10, DataUsageCacheSource::new(0, 0));
first_set.replace("bucket-b", DATA_USAGE_ROOT, DataUsageEntry::default());
first_set.replace("bucket-empty", DATA_USAGE_ROOT, DataUsageEntry::default());
let mut second_set = completed_root_cache("bucket-b", 2, 20, DataUsageCacheSource::new(1, 0));
second_set.replace("bucket-a", DATA_USAGE_ROOT, DataUsageEntry::default());
second_set.replace("bucket-empty", DATA_USAGE_ROOT, DataUsageEntry::default());
assert!(
completed_data_usage_info_for_test(&[first_set.clone(), DataUsageCache::default()], &all_buckets, false, false).is_none()
);
assert!(completed_data_usage_info_for_test(&[first_set.clone(), second_set.clone()], &all_buckets, true, false).is_none());
assert!(completed_data_usage_info_for_test(&[first_set.clone(), second_set.clone()], &all_buckets, false, true).is_none());
let (data_usage_info, last_update) = completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false)
.expect("all completed sets should produce a publishable data usage snapshot");
assert_eq!(last_update, SystemTime::UNIX_EPOCH + Duration::from_secs(20));
assert_eq!(data_usage_info.scanner_cycle, Some(0));
assert_eq!(data_usage_info.objects_total_count, 3);
assert_eq!(data_usage_info.buckets_usage.len(), 3);
assert!(data_usage_info.usage_snapshot_complete);
assert_eq!(
data_usage_info
.buckets_usage
.get("bucket-empty")
.map(|usage| (usage.objects_count, usage.size)),
Some((0, 0))
);
}
#[test]
fn completed_data_usage_info_publishes_confirmed_empty_namespace() {
let mut completed_set = DataUsageCache {
info: DataUsageCacheInfo {
name: DATA_USAGE_ROOT.to_string(),
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(10)),
source: Some(DataUsageCacheSource::new(0, 0)),
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
..Default::default()
},
..Default::default()
};
completed_set.replace(DATA_USAGE_ROOT, "", DataUsageEntry::default());
let (data_usage_info, _) = completed_data_usage_info_for_test(&[completed_set], &[], false, false)
.expect("a completed empty namespace should produce an authoritative snapshot");
assert!(data_usage_info.is_complete_bucket_usage_snapshot());
assert_eq!(data_usage_info.buckets_count, 0);
assert!(data_usage_info.buckets_usage.is_empty());
}
#[test]
fn complete_usage_candidate_with_changed_generation_is_superseded() {
let all_buckets = vec!["bucket".to_string()];
let completed_set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let completed_usage = completed_data_usage_info_for_test(&[completed_set], &all_buckets, false, false);
assert!(completed_usage.is_some());
assert_eq!(
classify_nsscanner_cycle(
completed_usage.is_some(),
false,
false,
ScannerBucketScanStatus::Complete,
DirtyUsageSnapshotStatus::Changed,
ScannerCycleActivityStatus::Unchanged,
),
ScannerCycleStatus::Superseded
);
}
#[test]
fn completed_data_usage_info_adds_same_bucket_across_unique_sets() {
let all_buckets = vec!["bucket".to_string()];
let first_set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let mut second_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
let second_entry = second_set.find("bucket").cloned().expect("second set bucket entry");
second_set.replace(
"bucket",
DATA_USAGE_ROOT,
DataUsageEntry {
versions: 4,
delete_markers: 1,
..second_entry
},
);
let (data_usage_info, last_update) = completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false)
.expect("unique completed set snapshots should be aggregated");
let bucket = data_usage_info.buckets_usage.get("bucket").expect("merged bucket usage");
assert_eq!(last_update, SystemTime::UNIX_EPOCH + Duration::from_secs(20));
assert_eq!(data_usage_info.objects_total_count, 5);
assert_eq!(data_usage_info.objects_total_size, 50);
assert_eq!(bucket.objects_count, 5);
assert_eq!(bucket.size, 50);
assert_eq!(bucket.versions_count, 4);
assert_eq!(bucket.delete_markers_count, 1);
}
#[test]
fn completed_data_usage_info_flattens_nested_bucket_entries() {
let all_buckets = vec!["bucket".to_string()];
let mut first_set = completed_root_cache("bucket", 1, 10, DataUsageCacheSource::new(0, 0));
let mut nested = DataUsageEntry {
objects: 2,
versions: 3,
size: 2048,
replication_stats: Some(ReplicationAllStats {
targets: HashMap::from([(
"arn:target".to_string(),
ReplicationStats {
replicated_size: 2048,
replicated_count: 2,
..Default::default()
},
)]),
replica_size: 2048,
replica_count: 2,
}),
..Default::default()
};
nested.obj_sizes.add(2048);
nested.obj_versions.add(3);
first_set.replace("bucket/prefix", "bucket", nested);
let second_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
let (data_usage_info, _) = completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false)
.expect("nested bucket entries should be flattened before aggregation");
let bucket = data_usage_info.buckets_usage.get("bucket").expect("merged bucket usage");
assert_eq!(data_usage_info.objects_total_count, 6);
assert_eq!(data_usage_info.objects_total_size, 2088);
assert_eq!(bucket.objects_count, 6);
assert_eq!(bucket.versions_count, 3);
assert_eq!(bucket.object_size_histogram["BETWEEN_1024_B_AND_64_KB"], 1);
assert_eq!(bucket.object_versions_histogram["BETWEEN_2_AND_10"], 1);
assert_eq!(bucket.replica_size, 2048);
assert_eq!(bucket.replica_count, 2);
assert_eq!(bucket.replication_info["arn:target"].replicated_size, 2048);
assert_eq!(bucket.replication_info["arn:target"].replicated_count, 2);
}
#[test]
fn completed_data_usage_info_rejects_cyclic_bucket_entries() {
let all_buckets = vec!["bucket".to_string()];
let mut cache = completed_root_cache("bucket", 1, 10, DataUsageCacheSource::new(0, 0));
cache.replace(
"bucket/prefix",
"bucket",
DataUsageEntry {
objects: 1,
size: 10,
..Default::default()
},
);
cache
.cache
.get_mut(&crate::hash_path("bucket/prefix").key())
.expect("nested entry")
.add_child(&crate::hash_path("bucket"));
assert!(completed_data_usage_info_for_test(&[cache], &all_buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_rejects_duplicate_or_incomplete_set_sources() {
let all_buckets = vec!["bucket".to_string()];
let source = DataUsageCacheSource::new(0, 0);
let first_set = completed_root_cache("bucket", 2, 10, source);
let duplicate_set = completed_root_cache("bucket", 3, 20, source);
assert!(completed_data_usage_info_for_test(&[first_set.clone(), duplicate_set], &all_buckets, false, false).is_none());
let mut incomplete_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
incomplete_set.info.snapshot_complete = false;
assert!(completed_data_usage_info_for_test(&[first_set, incomplete_set], &all_buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_requires_exact_topology_sources() {
let all_buckets = vec!["bucket".to_string()];
let first_set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let unexpected_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(99, 99));
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(1, 0)]);
assert!(
completed_data_usage_info(&[first_set, unexpected_set], &expected_sources, &all_buckets, true, false, false).is_none()
);
}
#[test]
fn completed_data_usage_info_rejects_incomplete_bucket_plan() {
let all_buckets = vec!["bucket".to_string()];
let set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let expected_sources = HashSet::from([DataUsageCacheSource::new(0, 0)]);
assert!(completed_data_usage_info(&[set], &expected_sources, &all_buckets, false, false, false).is_none());
}
#[test]
fn completed_data_usage_info_rejects_missing_bucket_from_any_set() {
let all_buckets = vec!["bucket-a".to_string(), "bucket-b".to_string()];
let mut complete_set = completed_root_cache("bucket-a", 2, 10, DataUsageCacheSource::new(0, 0));
complete_set.replace("bucket-b", DATA_USAGE_ROOT, DataUsageEntry::default());
let missing_bucket_set = completed_root_cache("bucket-a", 3, 20, DataUsageCacheSource::new(1, 0));
assert!(completed_data_usage_info_for_test(&[complete_set, missing_bucket_set], &all_buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_rejects_mixed_scan_plans() {
let all_buckets = vec!["bucket".to_string()];
let first_set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let mut second_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
second_set.info.scan_plan_digest = Some(DataUsageScanPlanDigest([8; 32]));
assert!(completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_rejects_mixed_scanner_cycles() {
let all_buckets = vec!["bucket".to_string()];
let mut first_set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let mut second_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
first_set.info.next_cycle = 12;
second_set.info.next_cycle = 13;
assert!(completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_rejects_mixed_leader_epochs() {
let all_buckets = vec!["bucket".to_string()];
let mut first_set = completed_root_cache("bucket", 2, 10, DataUsageCacheSource::new(0, 0));
let mut second_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
first_set.info.leader_epoch = 11;
second_set.info.leader_epoch = 12;
assert!(completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false).is_none());
}
#[test]
fn completed_data_usage_info_rejects_counter_overflow() {
let all_buckets = vec!["bucket".to_string()];
let first_set = completed_root_cache("bucket", usize::MAX, 10, DataUsageCacheSource::new(0, 0));
let second_set = completed_root_cache("bucket", 1, 20, DataUsageCacheSource::new(1, 0));
assert!(completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false).is_none());
}
#[test]
fn current_cache_snapshot_requires_matching_complete_source_and_cycle() {
let source = DataUsageCacheSource::new(1, 2);
let mut cache = completed_root_cache("bucket", 1, 10, source);
cache.info.next_cycle = 10;
assert!(cache_snapshot_is_current(&cache, DATA_USAGE_ROOT, source, 10, 0, TEST_PLAN_DIGEST));
assert!(!cache_snapshot_is_current(&cache, "bucket", source, 10, 0, TEST_PLAN_DIGEST));
assert!(!cache_snapshot_is_current(
&cache,
DATA_USAGE_ROOT,
DataUsageCacheSource::new(2, 1),
10,
0,
TEST_PLAN_DIGEST
));
assert!(!cache_snapshot_is_current(&cache, DATA_USAGE_ROOT, source, 11, 0, TEST_PLAN_DIGEST));
assert!(!cache_snapshot_is_current(
&cache,
DATA_USAGE_ROOT,
source,
10,
0,
DataUsageScanPlanDigest([8; 32])
));
cache.info.leader_epoch = 2;
assert!(!cache_snapshot_is_current(&cache, DATA_USAGE_ROOT, source, 10, 1, TEST_PLAN_DIGEST));
assert!(cache_snapshot_is_current(&cache, DATA_USAGE_ROOT, source, 10, 2, TEST_PLAN_DIGEST));
cache.info.next_cycle = 11;
assert!(!cache_snapshot_is_current(&cache, DATA_USAGE_ROOT, source, 10, 2, TEST_PLAN_DIGEST));
}
#[test]
fn current_cache_snapshot_rejects_persisted_windows_key_mismatch() {
let source = DataUsageCacheSource::new(1, 2);
let mut cache = DataUsageCache {
info: DataUsageCacheInfo {
name: "bucket".to_string(),
next_cycle: 10,
last_update: Some(SystemTime::UNIX_EPOCH),
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
..Default::default()
};
cache.cache.insert(
"bucket".to_string(),
DataUsageEntry {
children: HashSet::from(["bucket/prefix".to_string()]),
..Default::default()
},
);
cache.cache.insert(
"bucket\\prefix".to_string(),
DataUsageEntry {
objects: 3,
..Default::default()
},
);
assert!(!cache_snapshot_is_current(&cache, "bucket", source, 10, 0, TEST_PLAN_DIGEST));
match current_cache_root_or_prepare(&mut cache, "bucket", source, 10, 0, TEST_PLAN_DIGEST, true) {
DataUsageCacheScanState::Prepared {
outcome: DataUsageCachePrepareOutcome::Reset,
invalid_current: Some(_),
} => {}
_ => panic!("an invalid current cache must enter the rebuild path"),
}
assert!(cache.cache.is_empty());
assert_eq!(cache.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
}
#[test]
fn current_cache_snapshot_rejects_structurally_valid_legacy_key_format() {
let source = DataUsageCacheSource::new(1, 2);
let mut cache = DataUsageCache {
info: DataUsageCacheInfo {
name: "bucket".to_string(),
next_cycle: 10,
last_update: Some(SystemTime::UNIX_EPOCH),
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
..Default::default()
},
..Default::default()
};
cache.cache.insert(
"bucket".to_string(),
DataUsageEntry {
children: HashSet::from(["bucket\\prefix".to_string()]),
..Default::default()
},
);
cache.cache.insert(
"bucket\\prefix".to_string(),
DataUsageEntry {
objects: 3,
..Default::default()
},
);
assert_eq!(cache.checked_flatten("bucket").map(|entry| entry.objects), Some(3));
match current_cache_root_or_prepare(&mut cache, "bucket", source, 10, 0, TEST_PLAN_DIGEST, true) {
DataUsageCacheScanState::Prepared {
outcome: DataUsageCachePrepareOutcome::Reset,
invalid_current: None,
} => {}
_ => panic!("a legacy key format must enter the rebuild path"),
}
assert!(cache.cache.is_empty());
assert_eq!(cache.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
}
#[test]
fn current_cache_snapshot_rejects_current_bucket_cache_with_detached_entry() {
let source = DataUsageCacheSource::new(1, 2);
let mut cache = DataUsageCache {
info: DataUsageCacheInfo {
name: "bucket".to_string(),
next_cycle: 10,
last_update: Some(SystemTime::UNIX_EPOCH),
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(TEST_PLAN_DIGEST),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
..Default::default()
};
cache.cache.insert(
"bucket".to_string(),
DataUsageEntry {
objects: 1,
..Default::default()
},
);
cache.cache.insert(
"bucket/detached".to_string(),
DataUsageEntry {
objects: 2,
..Default::default()
},
);
assert_eq!(cache.checked_flatten("bucket").map(|entry| entry.objects), Some(1));
match current_cache_root_or_prepare(&mut cache, "bucket", source, 10, 0, TEST_PLAN_DIGEST, true) {
DataUsageCacheScanState::Prepared {
outcome: DataUsageCachePrepareOutcome::Reset,
invalid_current: Some(_),
} => {}
_ => panic!("a detached complete bucket cache must enter the rebuild path"),
}
assert!(cache.cache.is_empty());
assert_eq!(cache.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
}
#[test]
fn namespace_scanner_worker_selection_keeps_coordinator_fallback_disks() {
let server_epoch = uuid::Uuid::new_v4();
let workers = namespace_scanner_workers(vec!["local", "legacy-remote"], vec![("v4", server_epoch)]);
assert_eq!(
workers,
vec![
("local", NamespaceScannerWorkerMode::Coordinator),
("legacy-remote", NamespaceScannerWorkerMode::Coordinator),
("v4", NamespaceScannerWorkerMode::RemoteV4(server_epoch)),
]
);
assert!(namespace_scanner_workers::<()>(Vec::new(), Vec::new()).is_empty());
}
#[test]
fn remote_scanner_capability_probes_are_grouped_by_peer() {
let mut groups = group_remote_disks_by_peer(vec![("node-a", 0), ("node-b", 0), ("node-a", 1), ("node-b", 1)], |disk| {
disk.0.to_string()
});
groups.sort_by_key(|group| group[0].0);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0], vec![("node-a", 0), ("node-a", 1)]);
assert_eq!(groups[1], vec![("node-b", 0), ("node-b", 1)]);
}
#[test]
fn scanner_bucket_plan_digest_is_order_independent_and_membership_sensitive() {
let activity_digest = [4; 32];
let buckets = vec![
BucketInfo {
name: "photos".to_string(),
..Default::default()
},
BucketInfo {
name: "videos".to_string(),
..Default::default()
},
];
let reversed = vec![buckets[1].clone(), buckets[0].clone()];
let changed = vec![
buckets[0].clone(),
BucketInfo {
name: "archives".to_string(),
..Default::default()
},
];
let mut regenerated = buckets.clone();
regenerated[0].created = Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1));
assert_eq!(
scanner_bucket_plan_digest(&buckets, activity_digest),
scanner_bucket_plan_digest(&reversed, activity_digest)
);
assert_ne!(
scanner_bucket_plan_digest(&buckets, activity_digest),
scanner_bucket_plan_digest(&changed, activity_digest)
);
assert_ne!(
scanner_bucket_plan_digest(&buckets, activity_digest),
scanner_bucket_plan_digest(&regenerated, activity_digest)
);
assert_ne!(
scanner_bucket_plan_digest(&buckets, activity_digest),
scanner_bucket_plan_digest(&buckets, [5; 32])
);
}
#[test]
fn dirty_bucket_cache_digest_changes_with_generation() {
let source = DataUsageCacheSource::new(0, 0);
let plan = DataUsageScanPlanDigest([9; 32]);
let first = scanner_bucket_cache_digest(plan, Some(7));
let second = scanner_bucket_cache_digest(plan, Some(8));
let mut cache = DataUsageCache {
info: DataUsageCacheInfo {
name: "photos".to_string(),
next_cycle: 11,
last_update: Some(SystemTime::now()),
source: Some(source),
snapshot_complete: true,
scan_plan_digest: Some(first),
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
..Default::default()
},
..Default::default()
};
cache.replace("photos", "", DataUsageEntry::default());
assert_eq!(scanner_bucket_cache_digest(plan, None), plan);
assert!(cache_snapshot_is_current(&cache, "photos", source, 11, 0, first));
assert!(!cache_snapshot_is_current(&cache, "photos", source, 11, 0, second));
}
#[test]
fn scanner_cache_lock_resource_is_scoped_to_cache_source() {
let cache_name = "photos/.usage-cache.bin";
let first_source = DataUsageCacheSource::new(0, 1);
let same_source = DataUsageCacheSource::new(0, 1);
let other_source = DataUsageCacheSource::new(1, 0);
let first = scanner_cache_lock_resource(cache_name, first_source);
assert_eq!(first, scanner_cache_lock_resource(cache_name, same_source));
assert_ne!(first, scanner_cache_lock_resource(cache_name, other_source));
assert!(first.ends_with(".scanner-cycle.lock.pool-0.set-1"));
}
#[test]
fn count_budget_serializes_set_and_disk_work() {
assert_eq!(scanner_budgeted_concurrency_limit(8, true), 1);
assert_eq!(scanner_budgeted_concurrency_limit(8, false), 8);
}
#[test]
fn requeued_bucket_work_is_only_completed_after_retry() {
let remaining = Arc::new(AtomicUsize::new(1));
let complete = CancellationToken::new();
let mut first = BucketWorkGuard::new(remaining.clone(), complete.clone());
first.mark_requeued();
drop(first);
assert_eq!(remaining.load(Ordering::Acquire), 1);
assert!(!complete.is_cancelled());
drop(BucketWorkGuard::new(remaining.clone(), complete.clone()));
assert_eq!(remaining.load(Ordering::Acquire), 0);
assert!(complete.is_cancelled());
}
#[tokio::test]
async fn exhausted_workers_mark_all_queued_bucket_work_failed() {
let (tx, rx) = mpsc::channel(2);
tx.send(BucketInfo {
name: "photos".to_string(),
..Default::default()
})
.await
.expect("queue photos");
tx.send(BucketInfo {
name: "videos".to_string(),
..Default::default()
})
.await
.expect("queue videos");
drop(tx);
let receiver = Mutex::new(rx);
let remaining = Arc::new(AtomicUsize::new(2));
let complete = CancellationToken::new();
let failed = Arc::new(Mutex::new(HashSet::new()));
let failed_count = mark_unprocessed_bucket_work_failed(&receiver, &remaining, &complete, &failed).await;
assert_eq!(failed_count, 2);
assert_eq!(remaining.load(Ordering::Acquire), 0);
assert!(complete.is_cancelled());
assert_eq!(*failed.lock().await, HashSet::from(["photos".to_string(), "videos".to_string()]));
}
#[tokio::test]
async fn requeued_bucket_remains_pending_for_another_worker() {
let (tx, mut rx) = mpsc::channel(1);
let remaining = Arc::new(AtomicUsize::new(1));
let complete = CancellationToken::new();
let mut guard = BucketWorkGuard::new(remaining.clone(), complete.clone());
let bucket = BucketInfo {
name: "photos".to_string(),
..Default::default()
};
assert!(requeue_bucket_work(&tx, &bucket, &mut guard).await);
drop(guard);
assert_eq!(remaining.load(Ordering::Acquire), 1);
assert!(!complete.is_cancelled());
assert_eq!(rx.recv().await.expect("requeued bucket").name, "photos");
}