mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-08 04:58:12 +00:00
chore: merge main into scanner scope entry oracles
Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
This commit is contained in:
@@ -1888,9 +1888,9 @@ where
|
||||
// A remote restart or movement flip invalidates
|
||||
// the token proof; usage_store interprets this
|
||||
// as a publication barrier and performs no PUT.
|
||||
return true;
|
||||
return Some(ScannerCycleDeferReason::DataMovement);
|
||||
}
|
||||
storeapi.scanner_data_usage_publication_blocked().await
|
||||
scanner_local_publication_defer_reason(storeapi.as_ref()).await
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -3239,8 +3239,8 @@ where
|
||||
{
|
||||
match status {
|
||||
ScannerCycleStatus::Complete | ScannerCycleStatus::Superseded => {
|
||||
if storeapi.scanner_data_usage_publication_blocked().await {
|
||||
return Some(ScannerCycleDeferReason::DataMovement);
|
||||
if let Some(reason) = scanner_local_publication_defer_reason(storeapi).await {
|
||||
return Some(reason);
|
||||
}
|
||||
if status == ScannerCycleStatus::Complete {
|
||||
let distributed = storeapi.setup_is_dist_erasure().await;
|
||||
@@ -3263,6 +3263,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
async fn scanner_local_publication_defer_reason<S>(storeapi: &S) -> Option<ScannerCycleDeferReason>
|
||||
where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
if !storeapi.scanner_data_usage_publication_blocked().await {
|
||||
return None;
|
||||
}
|
||||
// Pending namespace commits invalidate this publication attempt, but only
|
||||
// storage movement creates durable, rate-limited catch-up debt.
|
||||
if storeapi.scanner_data_movement_pause_status().await.paused {
|
||||
Some(ScannerCycleDeferReason::DataMovement)
|
||||
} else {
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
fn scanner_post_lease_activity_defer_reason(
|
||||
expected_digest: Option<[u8; 32]>,
|
||||
activity: Result<ScannerActivitySnapshot, String>,
|
||||
|
||||
@@ -266,6 +266,11 @@ async fn running_main_loop_catches_up_pause_cleared_after_startup_observe() {
|
||||
}
|
||||
let pause_status = store.scanner_data_movement_pause_status().await;
|
||||
assert!(pause_status.paused);
|
||||
assert_eq!(
|
||||
scanner_local_publication_defer_reason(store.as_ref()).await,
|
||||
Some(ScannerCycleDeferReason::DataMovement),
|
||||
"an actual data-movement pause must retain durable catch-up tracking"
|
||||
);
|
||||
paused_probe.wait().await;
|
||||
drop(paused_probe);
|
||||
|
||||
@@ -1171,6 +1176,9 @@ async fn run_data_scanner_cycle_publishes_activity_for_owner_lifetime() {
|
||||
async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledging_usage() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||
let mut pause_backlog = ScannerPauseBacklogController::claim(store.clone(), scanner_pause_backlog_now())
|
||||
.await
|
||||
.expect("scanner pause backlog should be available");
|
||||
let bucket = format!("scanner-coordinator-pending-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &crate::storage_api::scan::MakeBucketOptions::default())
|
||||
@@ -1195,6 +1203,13 @@ async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledgin
|
||||
.await
|
||||
.expect("fixture usage baseline should be readable");
|
||||
let pending = ecstore_hold_namespace_commit(store.as_ref());
|
||||
assert_eq!(
|
||||
scanner_local_publication_defer_reason(store.as_ref()).await,
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||
"an ordinary namespace commit must not be classified as data movement"
|
||||
);
|
||||
let pause_backlog_attempt = pause_backlog.begin_attempt(scanner_pause_backlog_now()).await;
|
||||
assert_eq!(pause_backlog_attempt, ScannerPauseBacklogAttemptDecision::Untracked);
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(&ctx, ScannerCycleBudgetConfig::default());
|
||||
let mut cycle_info = CurrentCycle {
|
||||
@@ -1209,7 +1224,15 @@ async fn coordinator_walks_during_pending_put_without_persisting_or_acknowledgin
|
||||
.await
|
||||
.expect("the coordinator must finish its namespace walk while a PUT is pending");
|
||||
assert_eq!(budget.progress().0, 1, "the coordinator must reach actual object traversal");
|
||||
assert_eq!(outcome, ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::DataMovement));
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ScannerCycleOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
);
|
||||
finish_scanner_pause_backlog_cycle(&mut pause_backlog, &store, pause_backlog_attempt, outcome).await;
|
||||
let pause_backlog_status = scanner_pause_backlog_status(store.clone()).await;
|
||||
assert_eq!(pause_backlog_status.phase, ScannerPauseBacklogPhase::Idle);
|
||||
assert!(!pause_backlog_status.pending_full_scan);
|
||||
assert_eq!(pause_backlog_status.catch_up_attempts, 0);
|
||||
assert_eq!(cycle_info.next, 1, "a rejected publication must not advance the cycle");
|
||||
assert_eq!(revision, DataUsageCacheRevision::Missing);
|
||||
assert_eq!(crate::scanner_io::dirty_usage_buckets_for_tests(), dirty_before);
|
||||
@@ -5826,7 +5849,7 @@ async fn test_usage_save_object_not_found_defers_only_with_a_fresh_route_barrier
|
||||
let probe_calls = route_probe_calls.clone();
|
||||
async move {
|
||||
let call = probe_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
route_blocked && call > 1
|
||||
(route_blocked && call > 1).then_some(ScannerCycleDeferReason::DataMovement)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -5872,16 +5895,19 @@ async fn test_usage_save_route_barrier_prevents_missing_snapshot_creation() {
|
||||
data: None,
|
||||
revision: DataUsageCacheRevision::Missing,
|
||||
}),
|
||||
|| async { true },
|
||||
|| async { Some(ScannerCycleDeferReason::ActivityBaselineUnavailable) },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement));
|
||||
assert_eq!(
|
||||
outcome,
|
||||
DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
);
|
||||
assert!(!store.objects.lock().await.contains_key(&target_key));
|
||||
assert_eq!(
|
||||
store.put_counts.lock().await.get(&target_key),
|
||||
None,
|
||||
"the final pool-state fence must run before the first PUT"
|
||||
"the final publication fence must run before the first PUT"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5902,7 +5928,7 @@ async fn test_observational_usage_defers_when_authoritative_baseline_is_missing(
|
||||
receiver,
|
||||
None,
|
||||
None,
|
||||
|| async { false },
|
||||
|| async { None },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -5950,7 +5976,7 @@ async fn test_observational_usage_uses_fenced_backup_when_v2_primary_has_no_iden
|
||||
receiver,
|
||||
None,
|
||||
None,
|
||||
|| async { false },
|
||||
|| async { None },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -5991,7 +6017,7 @@ async fn test_observational_usage_uses_bootstrap_pending_primary_as_baseline() {
|
||||
receiver,
|
||||
None,
|
||||
None,
|
||||
|| async { false },
|
||||
|| async { None },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -6051,7 +6077,7 @@ async fn test_usage_route_barrier_precedes_durable_reconciliation() {
|
||||
data: Some(Bytes::from(snapshot_data)),
|
||||
revision: DataUsageCacheRevision::Etag("memory-1".to_string()),
|
||||
}),
|
||||
|| async { true },
|
||||
|| async { Some(ScannerCycleDeferReason::DataMovement) },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -6091,7 +6117,7 @@ async fn coordinator_does_not_put_after_remote_generation_flip() {
|
||||
// Model the remote lease holder flipping its movement generation
|
||||
// after the activity probe but before the coordinator's PUT.
|
||||
route_store.publication_admission_blocked.store(true, Ordering::Release);
|
||||
false
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -6129,7 +6155,7 @@ async fn coordinator_classifies_an_expired_publication_lease() {
|
||||
revision: DataUsageCacheRevision::Missing,
|
||||
}),
|
||||
ScannerPublicationFence::new(None, Some(expired), None),
|
||||
|| async { false },
|
||||
|| async { None },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -6208,7 +6234,7 @@ async fn test_deferred_usage_save_keeps_last_real_save_metric() {
|
||||
data: None,
|
||||
revision: DataUsageCacheRevision::Missing,
|
||||
}),
|
||||
|| async { true },
|
||||
|| async { Some(ScannerCycleDeferReason::DataMovement) },
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ pub(super) async fn store_data_usage_in_backend_with_outcome_for_epoch_and_basel
|
||||
receiver,
|
||||
leader_epoch,
|
||||
initial_baseline,
|
||||
|| async { false },
|
||||
|| async { None },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -280,7 +280,7 @@ pub(super) async fn store_data_usage_in_backend_with_outcome_for_epoch_and_basel
|
||||
) -> DataUsagePersistOutcome
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync,
|
||||
Fut: Future<Output = bool> + Send,
|
||||
Fut: Future<Output = Option<ScannerCycleDeferReason>> + Send,
|
||||
{
|
||||
store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch(
|
||||
ctx,
|
||||
@@ -308,7 +308,7 @@ pub(super) async fn store_data_usage_in_backend_with_outcome_for_epoch_and_basel
|
||||
) -> DataUsagePersistOutcome
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync,
|
||||
Fut: Future<Output = bool> + Send,
|
||||
Fut: Future<Output = Option<ScannerCycleDeferReason>> + Send,
|
||||
{
|
||||
store_data_usage_in_backend_with_outcome_for_epoch_and_baseline_and_route_probe_for_publication_epoch_and_lease_fence(
|
||||
ctx,
|
||||
@@ -336,7 +336,7 @@ pub(super) async fn store_data_usage_in_backend_with_outcome_for_epoch_and_basel
|
||||
) -> DataUsagePersistOutcome
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync,
|
||||
Fut: Future<Output = bool> + Send,
|
||||
Fut: Future<Output = Option<ScannerCycleDeferReason>> + Send,
|
||||
{
|
||||
let ScannerPublicationFence {
|
||||
expected_publication_epoch,
|
||||
@@ -374,18 +374,19 @@ where
|
||||
} else {
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str()
|
||||
};
|
||||
if route_probe().await {
|
||||
if let Some(reason) = route_probe().await {
|
||||
debug!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %target_path,
|
||||
state = "publication_blocked_before_reconcile",
|
||||
"Scanner data usage publication deferred by the pool-state fence"
|
||||
reason = reason.as_str(),
|
||||
path = %target_path,
|
||||
"Scanner data usage publication deferred by the publication fence"
|
||||
);
|
||||
global_metrics().record_scanner_usage_deferred(ScannerCycleDeferReason::DataMovement.as_str());
|
||||
outcome = DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||
global_metrics().record_scanner_usage_deferred(reason.as_str());
|
||||
outcome = DataUsagePersistOutcome::Deferred(reason);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -626,17 +627,18 @@ where
|
||||
if ctx.is_cancelled() {
|
||||
break 'updates;
|
||||
}
|
||||
if route_probe().await {
|
||||
if let Some(reason) = route_probe().await {
|
||||
debug!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %target_path,
|
||||
state = "publication_blocked_before_save",
|
||||
"Scanner data usage publication deferred by the final pool-state fence"
|
||||
reason = reason.as_str(),
|
||||
path = %target_path,
|
||||
"Scanner data usage publication deferred by the final publication fence"
|
||||
);
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||
break DataUsagePersistOutcome::Deferred(reason);
|
||||
}
|
||||
if remote_lease_expired(remote_lease_deadline) {
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::PublicationLeaseDeadlineExceeded);
|
||||
@@ -722,19 +724,19 @@ where
|
||||
);
|
||||
}
|
||||
Err(e @ EcstoreError::ObjectNotFound(_, _)) => {
|
||||
let route_blocked = route_probe().await;
|
||||
if route_blocked {
|
||||
if let Some(reason) = route_probe().await {
|
||||
warn!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
path = %target_path,
|
||||
state = "publication_deferred",
|
||||
reason = reason.as_str(),
|
||||
path = %target_path,
|
||||
error = %e,
|
||||
"Scanner data usage route is blocked by data movement; retrying later"
|
||||
"Scanner data usage route remains blocked; retrying later"
|
||||
);
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::DataMovement);
|
||||
break DataUsagePersistOutcome::Deferred(reason);
|
||||
}
|
||||
error!(
|
||||
target: "rustfs::scanner",
|
||||
|
||||
@@ -1294,6 +1294,8 @@ impl FolderScanner {
|
||||
}
|
||||
Err(e) => return Err(ScannerError::Io(e)),
|
||||
};
|
||||
#[cfg(test)]
|
||||
tests::enumeration_restart::observe_raw_entry(&dir_path, &entry.file_name(), &self.budget);
|
||||
pending_entry_progress = pending_entry_progress.saturating_add(1);
|
||||
if pending_entry_progress >= SCANNER_ENTRY_PROGRESS_BATCH
|
||||
|| last_entry_progress.elapsed() >= SCANNER_ENTRY_PROGRESS_INTERVAL
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
use std::sync::Mutex;
|
||||
|
||||
mod checkpoint_fixture;
|
||||
pub(super) mod enumeration_restart;
|
||||
|
||||
/// Reset the process-global alert cooldown map; test-only.
|
||||
fn reset_alert_cooldowns() {
|
||||
|
||||
@@ -20,6 +20,8 @@ use crate::{DataUsageCacheSource, DataUsageScanPlanDigest};
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
mod segment_observation;
|
||||
|
||||
const CACHE_NAME: &str = "bucket/checkpoint-fixture.bin";
|
||||
const STATIC_OBJECTS: u64 = 24;
|
||||
const MAX_CACHE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Fixture-only range diagnostics. No result is supplied to a scan selector.
|
||||
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const MAX_SEGMENTS: usize = 4;
|
||||
const MAX_SEGMENT_BYTES: usize = 128;
|
||||
const MAX_WALK_SAMPLES: usize = 32;
|
||||
const MAX_WALK_BYTES: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ProposalError {
|
||||
EntryLimit,
|
||||
ByteLimit,
|
||||
InvalidKey,
|
||||
}
|
||||
|
||||
// Keys come from successful fixture writes, not a production mutation stream.
|
||||
fn fixture_proposal(keys: &[&str]) -> Result<BTreeSet<String>, ProposalError> {
|
||||
let mut segments = BTreeSet::new();
|
||||
let mut bytes = 0;
|
||||
for key in keys {
|
||||
if key.is_empty() || key.contains(['\\', '\0']) || key.split('/').any(|part| matches!(part, "" | "." | "..")) {
|
||||
return Err(ProposalError::InvalidKey);
|
||||
}
|
||||
let segment = key.split('/').next().expect("validated nonempty key");
|
||||
if segments.contains(segment) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() == MAX_SEGMENTS {
|
||||
return Err(ProposalError::EntryLimit);
|
||||
}
|
||||
if segment.len() > MAX_SEGMENT_BYTES - bytes {
|
||||
return Err(ProposalError::ByteLimit);
|
||||
}
|
||||
bytes += segment.len();
|
||||
segments.insert(segment.to_string());
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_observation_fixture_proposal_bounds() {
|
||||
assert_eq!(fixture_proposal(&["hot/one", "hot/two"]), Ok(BTreeSet::from(["hot".to_string()])));
|
||||
assert_eq!(fixture_proposal(&["a", "b", "c", "d"]).expect("entry boundary").len(), MAX_SEGMENTS);
|
||||
assert_eq!(fixture_proposal(&["a", "b", "c", "d", "e"]), Err(ProposalError::EntryLimit));
|
||||
let exact = "x".repeat(MAX_SEGMENT_BYTES);
|
||||
assert!(fixture_proposal(&[&exact]).is_ok());
|
||||
assert_eq!(fixture_proposal(&[&exact, "y"]), Err(ProposalError::ByteLimit));
|
||||
let oversized = "x".repeat(MAX_SEGMENT_BYTES + 1);
|
||||
assert_eq!(fixture_proposal(&[&oversized]), Err(ProposalError::ByteLimit));
|
||||
for key in ["", "/hot", "hot/../cold", "hot//one", "hot\\one", "hot/\0"] {
|
||||
assert_eq!(fixture_proposal(&[key]), Err(ProposalError::InvalidKey));
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_value(cache: &DataUsageCache) -> serde_json::Value {
|
||||
let mut value = serde_json::to_value(cache).expect("serialize the entire cache");
|
||||
// Children are a HashSet: canonicalize only that unordered field, without
|
||||
// discarding any cache fields or changing ordered histogram arrays.
|
||||
for (path, entry) in &cache.cache {
|
||||
value["cache"][path]["children"] =
|
||||
serde_json::to_value(entry.children.iter().collect::<BTreeSet<_>>()).expect("canonical child set");
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
async fn walk_and_save(observe: bool) -> (Vec<String>, serde_json::Value) {
|
||||
let (mut scanner, root) = build_test_scanner().await;
|
||||
let _guard = TestGuard {
|
||||
temp_dir: Some(root.clone()),
|
||||
};
|
||||
for prefix in ["hot", "cold", "other"] {
|
||||
for leaf in ["one", "two"] {
|
||||
let object = format!("{prefix}/{leaf}");
|
||||
let mut metadata = FileMeta::new();
|
||||
let mut info = FileInfo::new(&object, 4, 2);
|
||||
info.volume = "bucket".to_string();
|
||||
info.name = object.clone();
|
||||
info.size = 1;
|
||||
info.mod_time = Some(time::OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid fixture timestamp"));
|
||||
info.metadata.insert("etag".to_string(), "before".to_string());
|
||||
metadata.add_version(info).expect("construct segment fixture metadata");
|
||||
write_test_object_metadata_bytes(&root, "bucket", &object, &metadata.marshal_msg().expect("encode metadata")).await;
|
||||
}
|
||||
}
|
||||
let changed_key = "hot/one";
|
||||
let changed_path = root.join("bucket").join(changed_key).join("xl.meta");
|
||||
let before = tokio::fs::read(&changed_path).await.expect("read initial hot metadata");
|
||||
let mut metadata = FileMeta::new();
|
||||
let mut info = FileInfo::new(changed_key, 4, 2);
|
||||
info.volume = "bucket".to_string();
|
||||
info.name = changed_key.to_string();
|
||||
info.size = 1;
|
||||
info.mod_time = Some(time::OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid fixture timestamp"));
|
||||
info.metadata.insert("etag".to_string(), "after!".to_string());
|
||||
metadata.add_version(info).expect("construct same-size hot mutation");
|
||||
write_test_object_metadata_bytes(&root, "bucket", changed_key, &metadata.marshal_msg().expect("encode hot mutation")).await;
|
||||
let after = tokio::fs::read(&changed_path)
|
||||
.await
|
||||
.expect("read back committed fixture mutation");
|
||||
assert_eq!(before.len(), after.len(), "fixture rewrite must keep metadata byte length unchanged");
|
||||
assert_ne!(before, after, "a changed key requires an observable successful fixture write");
|
||||
scanner.old_cache.info.name = "bucket".to_string();
|
||||
scanner.new_cache.info.name = "bucket".to_string();
|
||||
scanner.update_cache.info.name = "bucket".to_string();
|
||||
let paths = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let proposed_walked = Arc::new(Mutex::new(BTreeSet::<String>::new()));
|
||||
scanner.update_current_path = Arc::new({
|
||||
let paths = paths.clone();
|
||||
let proposed_walked = proposed_walked.clone();
|
||||
move |path: &str| {
|
||||
let mut paths = paths.lock().expect("lock bounded actual-walk samples");
|
||||
assert!(paths.len() < MAX_WALK_SAMPLES, "fixture walk exceeded its entry budget");
|
||||
let bytes: usize = paths.iter().map(String::len).sum();
|
||||
assert!(path.len() <= MAX_WALK_BYTES - bytes, "fixture walk exceeded its byte budget");
|
||||
paths.push(path.to_string());
|
||||
if observe {
|
||||
let proposed = fixture_proposal(&[changed_key]).expect("bounded successful fixture mutation");
|
||||
if let Some(segment) = path.strip_prefix("bucket/").and_then(|path| path.split('/').next())
|
||||
&& proposed.contains(segment)
|
||||
{
|
||||
proposed_walked
|
||||
.lock()
|
||||
.expect("lock bounded observed segments")
|
||||
.insert(segment.to_string());
|
||||
}
|
||||
}
|
||||
Box::pin(async {})
|
||||
}
|
||||
});
|
||||
scanner
|
||||
.scan_folder(
|
||||
CancellationToken::new(),
|
||||
CachedFolder {
|
||||
name: "bucket".to_string(),
|
||||
parent: None,
|
||||
object_heal_prob_div: 1,
|
||||
},
|
||||
&mut DataUsageEntry::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual folder walker must finish independently of diagnostics");
|
||||
let paths = paths.lock().expect("read walk samples").clone();
|
||||
assert!(!paths.is_empty());
|
||||
for prefix in ["hot", "cold", "other"] {
|
||||
assert!(
|
||||
paths.iter().any(|path| path == &format!("bucket/{prefix}")),
|
||||
"all fixture segments must actually be walked"
|
||||
);
|
||||
}
|
||||
let store = FixtureStore::new();
|
||||
let revisions = DataUsageCache::default()
|
||||
.load_with_revisions(store.clone(), CACHE_NAME)
|
||||
.await
|
||||
.expect("read empty fixture revisions");
|
||||
scanner
|
||||
.new_cache
|
||||
.save_with_revisions_for_epoch(store.clone(), CACHE_NAME, &revisions, 0)
|
||||
.await
|
||||
.expect("save actual walker output through the cache codec and revision gate");
|
||||
let loaded = store.strict_load().await;
|
||||
assert_eq!(loaded.checked_flatten("bucket").expect("complete fixture tree").objects, 6);
|
||||
assert_eq!(
|
||||
cache_value(&loaded),
|
||||
cache_value(&scanner.new_cache),
|
||||
"codec round-trip must retain the entire cache, not just aggregate size"
|
||||
);
|
||||
if observe {
|
||||
let proposed = proposed_walked.lock().expect("read callback observations").clone();
|
||||
assert_eq!(proposed, BTreeSet::from(["hot".to_string()]));
|
||||
let walked_segments: BTreeSet<_> = paths
|
||||
.iter()
|
||||
.filter_map(|path| path.strip_prefix("bucket/"))
|
||||
.filter_map(|path| path.split('/').next())
|
||||
.collect();
|
||||
assert_eq!(walked_segments, BTreeSet::from(["cold", "hot", "other"]));
|
||||
assert!(proposed.iter().all(|segment| walked_segments.contains(segment.as_str())));
|
||||
assert_eq!(
|
||||
walked_segments.len() - proposed.len(),
|
||||
2,
|
||||
"the two non-proposed segments must still be walked"
|
||||
);
|
||||
eprintln!(
|
||||
"segment fixture: proposed={proposed:?}, actual_segments={walked_segments:?}, actual_walk_callbacks={}, production_producer_coverage=unverified",
|
||||
paths.len()
|
||||
);
|
||||
} else {
|
||||
assert!(proposed_walked.lock().expect("read disabled observations").is_empty());
|
||||
}
|
||||
// Compare semantic values because map encoding order is not content identity.
|
||||
(paths, cache_value(&loaded))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn segment_observation_on_off_preserves_actual_walk_and_saved_cache() {
|
||||
let off = walk_and_save(false).await;
|
||||
let on = walk_and_save(true).await;
|
||||
assert_eq!(off.0, on.0, "diagnostics must not change actual traversal order or coverage");
|
||||
assert_eq!(off.1, on.1, "diagnostics must not change the saved cache result");
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const MAX_CACHE_BYTES: u64 = 1024 * 1024;
|
||||
const REQUEST_ENV: &str = "RUSTFS_ENUMERATION_REQUEST";
|
||||
|
||||
struct Observation {
|
||||
root: PathBuf,
|
||||
limit: u64,
|
||||
entries: u64,
|
||||
name_bytes: u64,
|
||||
}
|
||||
|
||||
static OBSERVATION: Mutex<Option<Observation>> = Mutex::new(None);
|
||||
|
||||
// Only the selected synthetic disk is observed; concurrent unrelated scanners
|
||||
// do not consume its budget. This hook is absent from non-test builds.
|
||||
pub(in crate::scanner_folder) fn observe_raw_entry(dir: &str, name: &std::ffi::OsStr, budget: &ScannerCycleBudget) {
|
||||
let mut guard = OBSERVATION.lock().expect("enumeration observation lock");
|
||||
if let Some(observation) = guard.as_mut()
|
||||
&& Path::new(dir).starts_with(&observation.root)
|
||||
{
|
||||
observation.entries += 1;
|
||||
observation.name_bytes += u64::try_from(name.as_encoded_bytes().len()).expect("bounded entry name");
|
||||
if observation.entries >= observation.limit {
|
||||
budget.cancel_for_runtime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ObservationGuard;
|
||||
|
||||
impl Drop for ObservationGuard {
|
||||
fn drop(&mut self) {
|
||||
*OBSERVATION.lock().expect("enumeration observation cleanup") = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request {
|
||||
workspace: PathBuf,
|
||||
objects: usize,
|
||||
raw_entry_budget: u64,
|
||||
round: u32,
|
||||
}
|
||||
|
||||
async fn read_bounded(path: &Path) -> Vec<u8> {
|
||||
let file = tokio::fs::File::open(path).await.expect("open fixture artifact");
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_CACHE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.expect("read fixture artifact");
|
||||
assert!(u64::try_from(bytes.len()).expect("artifact size") <= MAX_CACHE_BYTES);
|
||||
bytes
|
||||
}
|
||||
|
||||
async fn round(request: &Request) -> serde_json::Value {
|
||||
assert!((1..=1024).contains(&request.objects));
|
||||
assert!((1..=4096).contains(&request.raw_entry_budget));
|
||||
assert!(request.round < 64);
|
||||
let disk_root = request.workspace.join("disk");
|
||||
let cache_path = request.workspace.join("cache.bin");
|
||||
if request.round == 0 {
|
||||
tokio::fs::create_dir(&disk_root).await.expect("create fresh synthetic disk");
|
||||
for index in 0..request.objects {
|
||||
let object = format!("object-{index:04}");
|
||||
let version = Uuid::from_u128(u128::try_from(index).expect("fixture index") + 1);
|
||||
let bytes = metadata_for_object_version("bucket", &object, Some(version));
|
||||
write_test_object_metadata_bytes(&disk_root, "bucket", &object, &bytes).await;
|
||||
}
|
||||
let mut initial = DataUsageCache::default();
|
||||
initial.info.name = "bucket".to_string();
|
||||
initial.info.skip_healing = true;
|
||||
initial.info.snapshot_complete = false;
|
||||
initial.replace("bucket", "", DataUsageEntry::default());
|
||||
tokio::fs::write(&cache_path, initial.marshal_msg().expect("initial cache codec"))
|
||||
.await
|
||||
.expect("persist initial cache");
|
||||
}
|
||||
let cache = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload cache codec before scan");
|
||||
assert_eq!(cache.info.name, "bucket");
|
||||
let before = cache.checked_flatten("bucket").expect("persisted bucket root").objects;
|
||||
let endpoint = Endpoint::try_from(disk_root.to_string_lossy().as_ref()).expect("fixture endpoint");
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("open synthetic disk in this process");
|
||||
let parent = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(&parent, Default::default());
|
||||
*OBSERVATION.lock().expect("install observation") = Some(Observation {
|
||||
root: disk.path(),
|
||||
limit: request.raw_entry_budget,
|
||||
entries: 0,
|
||||
name_bytes: 0,
|
||||
});
|
||||
let _observation_guard = ObservationGuard;
|
||||
let result = scan_data_folder(
|
||||
budget.token(),
|
||||
budget.clone(),
|
||||
vec![disk.clone()],
|
||||
disk,
|
||||
cache.clone(),
|
||||
None,
|
||||
HealScanMode::Normal,
|
||||
SCANNER_SLEEPER.clone(),
|
||||
)
|
||||
.await;
|
||||
let (returned, outcome) = match result {
|
||||
Ok(cache) => (cache, "complete"),
|
||||
Err(ScannerError::PartialCache(cache)) => (*cache, "partial"),
|
||||
Err(ScannerError::Other(message)) if budget.token().is_cancelled() && message == "Operation cancelled" => {
|
||||
(cache, "cancelled_without_cache")
|
||||
}
|
||||
Err(error) => panic!("unexpected real scanner failure: {error}"),
|
||||
};
|
||||
let encoded = returned.marshal_msg().expect("returned cache codec");
|
||||
assert!(u64::try_from(encoded.len()).expect("encoded length") <= MAX_CACHE_BYTES);
|
||||
tokio::fs::write(&cache_path, encoded).await.expect("persist returned cache");
|
||||
let reloaded = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload returned cache codec");
|
||||
let retained = reloaded.checked_flatten("bucket").expect("reloaded bucket root");
|
||||
let scanned = returned.checked_flatten("bucket").expect("returned bucket root");
|
||||
assert_eq!(
|
||||
(retained.objects, retained.versions, retained.size),
|
||||
(scanned.objects, scanned.versions, scanned.size)
|
||||
);
|
||||
assert_eq!(reloaded.info.snapshot_complete, returned.info.snapshot_complete);
|
||||
let guard = OBSERVATION.lock().expect("read observation");
|
||||
let observation = guard.as_ref().expect("installed observation");
|
||||
serde_json::json!({
|
||||
"schema": 1, "pid": std::process::id(), "round": request.round,
|
||||
"objects_expected": request.objects, "raw_entry_budget": request.raw_entry_budget,
|
||||
"raw_entries": observation.entries, "raw_name_bytes": observation.name_bytes,
|
||||
"objects_processed": budget.progress().0,
|
||||
"objects_before": before, "objects_retained": retained.objects,
|
||||
"versions_retained": retained.versions, "bytes_retained": retained.size,
|
||||
"snapshot_complete": reloaded.info.snapshot_complete, "outcome": outcome,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default CI is a positive healthy control. The external driver selects the
|
||||
/// same worker in a fresh OS process per round and applies its strict oracle.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn enumeration_restart_worker() {
|
||||
if let Some(path) = std::env::var_os(REQUEST_ENV) {
|
||||
let request: Request = serde_json::from_slice(&read_bounded(Path::new(&path)).await).expect("bounded worker request");
|
||||
let report = round(&request).await;
|
||||
tokio::fs::write(
|
||||
request.workspace.join(format!("round-{}.json", request.round)),
|
||||
serde_json::to_vec(&report).expect("report JSON"),
|
||||
)
|
||||
.await
|
||||
.expect("write worker report");
|
||||
} else {
|
||||
let temp = tempfile::tempdir().expect("healthy fixture directory");
|
||||
let report = round(&Request {
|
||||
workspace: temp.path().to_path_buf(),
|
||||
objects: 4,
|
||||
raw_entry_budget: 16,
|
||||
round: 0,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(report["outcome"], "complete");
|
||||
assert_eq!(report["snapshot_complete"], true);
|
||||
assert_eq!(report["objects_retained"], 4);
|
||||
assert_eq!(report["versions_retained"], 4);
|
||||
assert_eq!(report["bytes_retained"], 4);
|
||||
assert!(report["raw_entries"].as_u64().expect("observed entries") >= 8, "{report}");
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,16 @@ async fn setup_two_pool_scanner_store() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||
(temp_dir, store)
|
||||
}
|
||||
|
||||
async fn wait_for_namespace_commit_tails(store: &ECStore) {
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while store.scanner_data_usage_publication_blocked().await {
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("namespace commit tails should drain before the scanner fixture runs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkpoint_fixture_bucket_identity_uses_its_set_instance_owner() {
|
||||
@@ -336,6 +346,7 @@ async fn scoped_scan_production_entry_preserves_deep_and_full_maintenance_work()
|
||||
.await
|
||||
.expect("initial object should persist");
|
||||
}
|
||||
wait_for_namespace_commit_tails(store.as_ref()).await;
|
||||
let mut baseline = None;
|
||||
for (index, (scan_mode, requires_full_scan, explicit_scope)) in [
|
||||
(HealScanMode::Normal, true, false),
|
||||
@@ -354,6 +365,7 @@ async fn scoped_scan_production_entry_preserves_deep_and_full_maintenance_work()
|
||||
.put_object("cold-bucket", &format!("added-{index}"), &mut reader, &ScannerObjectOptions::default())
|
||||
.await
|
||||
.expect("cold bucket mutation should persist");
|
||||
wait_for_namespace_commit_tails(store.as_ref()).await;
|
||||
// Only the hot bucket is in the usage hint. The cold result must
|
||||
// come from this cycle's storage walk, not its previous baseline.
|
||||
record_dirty_usage_bucket("hot-bucket");
|
||||
|
||||
@@ -1020,6 +1020,7 @@ mod serial_tests {
|
||||
}
|
||||
|
||||
let (_disk_paths, ecstore) = setup_isolated_test_env(false).await;
|
||||
let expired_recovery_time = i128::from(i64::MAX / 2);
|
||||
|
||||
for case in [
|
||||
CleanupCase::Persisted,
|
||||
@@ -1117,7 +1118,7 @@ mod serial_tests {
|
||||
.await
|
||||
.expect("active unknown ownership must remain fenced after the transaction store was offline");
|
||||
assert_eq!((retained.scanned, retained.recovered, retained.retained, retained.failed), (1, 0, 1, 0));
|
||||
let recovered = recover_transition_transaction_records_at(ecstore.clone(), 100, None, i128::MAX)
|
||||
let recovered = recover_transition_transaction_records_at(ecstore.clone(), 100, None, expired_recovery_time)
|
||||
.await
|
||||
.expect("expired unknown ownership may use the provider's missing proof");
|
||||
assert_eq!(
|
||||
@@ -1166,7 +1167,7 @@ mod serial_tests {
|
||||
assert_eq!(retained.recovered, 0);
|
||||
assert_eq!(retained.retained + retained.failed, 1);
|
||||
backend.set_remove_failure(false);
|
||||
let recovered = recover_transition_transaction_records_at(ecstore.clone(), 100, None, i128::MAX)
|
||||
let recovered = recover_transition_transaction_records_at(ecstore.clone(), 100, None, expired_recovery_time)
|
||||
.await
|
||||
.expect("expired recovery should delete the candidate after the backend becomes available");
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user