mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-22 20:36:38 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 629e477da2 | |||
| 4fd3d7b643 | |||
| 79a8371479 | |||
| 69ae9ef671 | |||
| db709aee57 | |||
| da90d02c15 | |||
| a930152d5a | |||
| f9d45e41e1 | |||
| 04e1ea227a | |||
| 16c56fe0bd | |||
| 1a3be70d98 |
@@ -317,8 +317,6 @@ pub mod config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod data_usage {
|
pub mod data_usage {
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
pub use crate::data_usage::seed_bucket_usage_memory_for_test;
|
|
||||||
pub use crate::data_usage::{
|
pub use crate::data_usage::{
|
||||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||||
@@ -330,6 +328,8 @@ pub mod data_usage {
|
|||||||
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
||||||
store_data_usage_in_backend,
|
store_data_usage_in_backend,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::data_usage::{get_bucket_usage_memory, seed_bucket_usage_memory_for_test};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod disk {
|
pub mod disk {
|
||||||
|
|||||||
@@ -3425,6 +3425,44 @@ mod tests {
|
|||||||
assert!(mutexes.contains_key("second"));
|
assert!(mutexes.contains_key("second"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_all_targets_publishes_disable_proxy_on_target_client() {
|
||||||
|
// The read-proxy selector (replication_proxy::get_proxy_targets) skips
|
||||||
|
// targets whose TargetClient carries disable_proxy — the persisted
|
||||||
|
// per-target opt-out must survive client publication.
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let target = |arn: &str, disable_proxy: bool| BucketTarget {
|
||||||
|
arn: arn.to_string(),
|
||||||
|
endpoint: "192.168.1.10:9000".to_string(),
|
||||||
|
target_bucket: "target-bucket".to_string(),
|
||||||
|
region: "us-east-1".to_string(),
|
||||||
|
disable_proxy,
|
||||||
|
credentials: Some(Credentials {
|
||||||
|
access_key: "access".to_string(),
|
||||||
|
secret_key: "secret".to_string(),
|
||||||
|
session_token: None,
|
||||||
|
expiration: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let targets = BucketTargets {
|
||||||
|
targets: vec![target("arn:proxied", false), target("arn:opted-out", true)],
|
||||||
|
};
|
||||||
|
|
||||||
|
sys.update_all_targets("bucket", Some(&targets)).await;
|
||||||
|
|
||||||
|
let proxied = sys
|
||||||
|
.get_remote_target_client("bucket", "arn:proxied")
|
||||||
|
.await
|
||||||
|
.expect("client should be published");
|
||||||
|
assert!(!proxied.disable_proxy);
|
||||||
|
let opted_out = sys
|
||||||
|
.get_remote_target_client("bucket", "arn:opted-out")
|
||||||
|
.await
|
||||||
|
.expect("client should be published");
|
||||||
|
assert!(opted_out.disable_proxy, "disable_proxy must reach the published TargetClient");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn target_updates_serialize_client_build_through_publication_per_bucket() {
|
async fn target_updates_serialize_client_build_through_publication_per_bucket() {
|
||||||
let sys = Arc::new(BucketTargetSys::default());
|
let sys = Arc::new(BucketTargetSys::default());
|
||||||
|
|||||||
@@ -2855,7 +2855,7 @@ fn replicate_object_info_from_object_info(
|
|||||||
.map(|v| OffsetDateTime::parse(&v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
|
.map(|v| OffsetDateTime::parse(&v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
|
||||||
let mut rstate = oi.replication_state();
|
let mut rstate = oi.replication_state();
|
||||||
rstate.replicate_decision_str = dsc.to_string();
|
rstate.replicate_decision_str = dsc.to_string();
|
||||||
let asz = oi.get_actual_size().unwrap_or_default();
|
let asz = oi.get_actual_size_or_physical();
|
||||||
let ssec = replication_object_is_ssec_encrypted(&oi.user_defined);
|
let ssec = replication_object_is_ssec_encrypted(&oi.user_defined);
|
||||||
let checksum = if ssec { oi.checksum.clone() } else { None };
|
let checksum = if ssec { oi.checksum.clone() } else { None };
|
||||||
|
|
||||||
|
|||||||
@@ -1412,7 +1412,7 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
|
|||||||
};
|
};
|
||||||
let mut replication_state = oi.replication_state();
|
let mut replication_state = oi.replication_state();
|
||||||
replication_state.replicate_decision_str = dsc.to_string();
|
replication_state.replicate_decision_str = dsc.to_string();
|
||||||
let actual_size = oi.get_actual_size().unwrap_or_default();
|
let actual_size = oi.get_actual_size_or_physical();
|
||||||
|
|
||||||
Ok(ReplicateObjectInfo {
|
Ok(ReplicateObjectInfo {
|
||||||
name: oi.name.clone(),
|
name: oi.name.clone(),
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ fn replication_source_object(object_info: &ObjectInfo) -> ReplicationSourceObjec
|
|||||||
.map(|mod_time| OffsetDateTime::from_unix_timestamp(mod_time.unix_timestamp()).unwrap_or(mod_time)),
|
.map(|mod_time| OffsetDateTime::from_unix_timestamp(mod_time.unix_timestamp()).unwrap_or(mod_time)),
|
||||||
version_id: object_info.version_id.map(|version_id| version_id.to_string()),
|
version_id: object_info.version_id.map(|version_id| version_id.to_string()),
|
||||||
etag: object_info.etag.as_deref(),
|
etag: object_info.etag.as_deref(),
|
||||||
actual_size: object_info.get_actual_size().unwrap_or_default(),
|
actual_size: object_info.get_actual_size_or_physical(),
|
||||||
delete_marker: object_info.delete_marker,
|
delete_marker: object_info.delete_marker,
|
||||||
content_type: object_info.content_type.as_deref(),
|
content_type: object_info.content_type.as_deref(),
|
||||||
content_encoding: object_info.content_encoding.as_deref(),
|
content_encoding: object_info.content_encoding.as_deref(),
|
||||||
@@ -542,6 +542,20 @@ mod tests {
|
|||||||
assert!(replication_target_head_is_newer_null_version(&source, &target));
|
assert!(replication_target_head_is_newer_null_version(&source, &target));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_source_uses_physical_size_for_unknown_compressed_object() {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_COMPRESSION, "zstd".to_string());
|
||||||
|
let source = ObjectInfo {
|
||||||
|
size: 128,
|
||||||
|
actual_size: -1,
|
||||||
|
user_defined: Arc::new(metadata),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(replication_source_object(&source).actual_size, 128);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replication_target_head_content_matches_compare_etag_only() {
|
fn replication_target_head_content_matches_compare_etag_only() {
|
||||||
let source = ObjectInfo {
|
let source = ObjectInfo {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::storage_api_contracts::{
|
|||||||
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
|
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
|
||||||
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
|
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
|
||||||
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
|
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
|
||||||
object::{DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
|
object::{DeleteAccounting, DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
|
||||||
range::HTTPRangeSpec,
|
range::HTTPRangeSpec,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -414,6 +414,66 @@ fn apply_delete_objects_results(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_delete_accounting_results(
|
||||||
|
accounting: &mut [Option<DeleteAccounting>],
|
||||||
|
set_objects: &[DelObj],
|
||||||
|
set_accounting: &[Option<DeleteAccounting>],
|
||||||
|
) {
|
||||||
|
for (obj, value) in set_objects.iter().zip(set_accounting.iter()) {
|
||||||
|
accounting[obj.orig_idx] = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sets {
|
||||||
|
pub(crate) async fn delete_objects_with_accounting(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
objects: Vec<ObjectToDelete>,
|
||||||
|
opts: ObjectOptions,
|
||||||
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||||
|
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
||||||
|
let mut del_errs = vec![None; objects.len()];
|
||||||
|
let mut accounting = vec![None; objects.len()];
|
||||||
|
let mut set_obj_map = HashMap::new();
|
||||||
|
|
||||||
|
for (i, obj) in objects.iter().enumerate() {
|
||||||
|
let idx = self.get_hashed_set_index(obj.object_name.as_str());
|
||||||
|
set_obj_map.entry(idx).or_insert_with(Vec::new).push(DelObj {
|
||||||
|
orig_idx: i,
|
||||||
|
obj: obj.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_concurrent = set_obj_map.len().min(num_cpus::get()).max(1);
|
||||||
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
||||||
|
let mut futures = FuturesUnordered::new();
|
||||||
|
let bucket = bucket.to_owned();
|
||||||
|
|
||||||
|
for (set_index, set_objects) in set_obj_map {
|
||||||
|
let disks = self.get_disks(set_index);
|
||||||
|
let objects = set_objects.iter().map(|entry| entry.obj.clone()).collect::<Vec<_>>();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
let opts = opts.clone();
|
||||||
|
let semaphore = semaphore.clone();
|
||||||
|
futures.push(async move {
|
||||||
|
let _permit = semaphore
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.expect("delete_objects semaphore should remain open");
|
||||||
|
let (deleted, errors, accounting) = disks.delete_objects_with_accounting(&bucket, objects, opts).await;
|
||||||
|
(set_objects, deleted, errors, accounting)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some((set_objects, deleted, errors, set_accounting)) = futures.next().await {
|
||||||
|
apply_delete_objects_results(&mut del_objects, &mut del_errs, &set_objects, &deleted, errors);
|
||||||
|
apply_delete_accounting_results(&mut accounting, &set_objects, &set_accounting);
|
||||||
|
}
|
||||||
|
|
||||||
|
(del_objects, del_errs, accounting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl crate::storage_api_contracts::object::ObjectIO for Sets {
|
impl crate::storage_api_contracts::object::ObjectIO for Sets {
|
||||||
type Error = Error;
|
type Error = Error;
|
||||||
@@ -655,65 +715,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
|
|||||||
objects: Vec<ObjectToDelete>,
|
objects: Vec<ObjectToDelete>,
|
||||||
opts: ObjectOptions,
|
opts: ObjectOptions,
|
||||||
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
|
||||||
// Default return value
|
let (deleted, errors, _) = self.delete_objects_with_accounting(bucket, objects, opts).await;
|
||||||
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
(deleted, errors)
|
||||||
|
|
||||||
let mut del_errs = Vec::with_capacity(objects.len());
|
|
||||||
for _ in 0..objects.len() {
|
|
||||||
del_errs.push(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut set_obj_map = HashMap::new();
|
|
||||||
|
|
||||||
// hash key
|
|
||||||
for (i, obj) in objects.iter().enumerate() {
|
|
||||||
let idx = self.get_hashed_set_index(obj.object_name.as_str());
|
|
||||||
|
|
||||||
if !set_obj_map.contains_key(&idx) {
|
|
||||||
set_obj_map.insert(
|
|
||||||
idx,
|
|
||||||
vec![DelObj {
|
|
||||||
// set_idx: idx,
|
|
||||||
orig_idx: i,
|
|
||||||
obj: obj.clone(),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
} else if let Some(val) = set_obj_map.get_mut(&idx) {
|
|
||||||
val.push(DelObj {
|
|
||||||
// set_idx: idx,
|
|
||||||
orig_idx: i,
|
|
||||||
obj: obj.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_concurrent = set_obj_map.len().min(num_cpus::get()).max(1);
|
|
||||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
|
|
||||||
let mut futures = FuturesUnordered::new();
|
|
||||||
let bucket = bucket.to_string();
|
|
||||||
|
|
||||||
for (k, v) in set_obj_map {
|
|
||||||
let disks = self.get_disks(k);
|
|
||||||
let objs: Vec<ObjectToDelete> = v.iter().map(|v| v.obj.clone()).collect();
|
|
||||||
let bucket = bucket.clone();
|
|
||||||
let opts = opts.clone();
|
|
||||||
let semaphore = semaphore.clone();
|
|
||||||
|
|
||||||
futures.push(async move {
|
|
||||||
let _permit = semaphore
|
|
||||||
.acquire_owned()
|
|
||||||
.await
|
|
||||||
.expect("delete_objects semaphore should remain open");
|
|
||||||
let (dobjects, errs) = disks.delete_objects(&bucket, objs, opts).await;
|
|
||||||
(v, dobjects, errs)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Some((v, dobjects, errs)) = futures.next().await {
|
|
||||||
apply_delete_objects_results(&mut del_objects, &mut del_errs, &v, &dobjects, errs);
|
|
||||||
}
|
|
||||||
|
|
||||||
(del_objects, del_errs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self))]
|
||||||
|
|||||||
@@ -1391,7 +1391,37 @@ impl BucketUsageAccumulator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
|
pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
|
||||||
let logical_size = u64::try_from(object.get_actual_size().map_err(Error::other)?).map_err(|_| Error::PartMissingOrCorrupt)?;
|
// A compressed object may carry -1 while the transformed size is unknown
|
||||||
|
// (legacy streaming sentinel). In that case the persisted physical size
|
||||||
|
// is still a valid accounting floor; every other negative value is corrupt.
|
||||||
|
// An explicit negative `actual-size` metadata value is corrupt, however:
|
||||||
|
// the sentinel is only valid in the in-memory/object-part field written by
|
||||||
|
// the legacy streaming path, not as a persisted declared size.
|
||||||
|
let compressed = object.is_compressed();
|
||||||
|
if object.actual_size < -1 || (object.actual_size == -1 && !compressed) {
|
||||||
|
return Err(Error::PartMissingOrCorrupt);
|
||||||
|
}
|
||||||
|
if object
|
||||||
|
.parts
|
||||||
|
.iter()
|
||||||
|
.any(|part| part.actual_size < -1 || (part.actual_size < 0 && !compressed))
|
||||||
|
{
|
||||||
|
return Err(Error::PartMissingOrCorrupt);
|
||||||
|
}
|
||||||
|
let declared_actual_size = rustfs_utils::http::get_str(&object.user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
if declared_actual_size
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
|
.is_some_and(|size| size < 0)
|
||||||
|
{
|
||||||
|
return Err(Error::PartMissingOrCorrupt);
|
||||||
|
}
|
||||||
|
let logical_size = match object.get_actual_size().map_err(Error::other)? {
|
||||||
|
size if size == -1 && compressed && declared_actual_size.is_none() => None,
|
||||||
|
size if size >= 0 => Some(u64::try_from(size).map_err(|_| Error::PartMissingOrCorrupt)?),
|
||||||
|
_ => return Err(Error::PartMissingOrCorrupt),
|
||||||
|
};
|
||||||
let persisted_part_size = if object.parts.is_empty() {
|
let persisted_part_size = if object.parts.is_empty() {
|
||||||
u64::try_from(object.size).map_err(|_| Error::PartMissingOrCorrupt)?
|
u64::try_from(object.size).map_err(|_| Error::PartMissingOrCorrupt)?
|
||||||
} else {
|
} else {
|
||||||
@@ -1399,12 +1429,8 @@ pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
|
|||||||
// Compressed streaming objects persist -1 when the transformed
|
// Compressed streaming objects persist -1 when the transformed
|
||||||
// part size is unknown. The physical part size remains a valid
|
// part size is unknown. The physical part size remains a valid
|
||||||
// quota floor; reject only non-negative values that overflow.
|
// quota floor; reject only non-negative values that overflow.
|
||||||
let actual_size = if part.actual_size < 0 {
|
let actual_size = if part.actual_size == -1 {
|
||||||
if object.is_compressed() {
|
0
|
||||||
0
|
|
||||||
} else {
|
|
||||||
return Err(Error::PartMissingOrCorrupt);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
u64::try_from(part.actual_size).map_err(|_| Error::PartMissingOrCorrupt)?
|
u64::try_from(part.actual_size).map_err(|_| Error::PartMissingOrCorrupt)?
|
||||||
};
|
};
|
||||||
@@ -1412,7 +1438,7 @@ pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
|
|||||||
total.checked_add(part_size).ok_or(Error::PartMissingOrCorrupt)
|
total.checked_add(part_size).ok_or(Error::PartMissingOrCorrupt)
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
Ok(logical_size.max(persisted_part_size))
|
Ok(logical_size.unwrap_or(0).max(persisted_part_size))
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>;
|
type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||||
@@ -3320,6 +3346,80 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_object_size_accepts_compressed_unknown_actual_size_sentinel() {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
rustfs_utils::http::insert_str(
|
||||||
|
&mut metadata,
|
||||||
|
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||||
|
"klauspost/compress/s2".to_string(),
|
||||||
|
);
|
||||||
|
let object = ObjectInfo {
|
||||||
|
size: 400,
|
||||||
|
actual_size: -1,
|
||||||
|
user_defined: Arc::new(metadata),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(quota_object_size(&object).expect("compressed sentinel is valid"), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_object_size_rejects_compressed_part_sum_overflow() {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
rustfs_utils::http::insert_str(
|
||||||
|
&mut metadata,
|
||||||
|
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||||
|
"klauspost/compress/s2".to_string(),
|
||||||
|
);
|
||||||
|
let object = ObjectInfo {
|
||||||
|
size: 1,
|
||||||
|
user_defined: Arc::new(metadata),
|
||||||
|
parts: Arc::new(vec![
|
||||||
|
rustfs_filemeta::ObjectPartInfo {
|
||||||
|
actual_size: i64::MAX,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
rustfs_filemeta::ObjectPartInfo {
|
||||||
|
actual_size: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(quota_object_size(&object), Err(Error::Io(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_object_size_rejects_negative_values_other_than_the_compressed_sentinel() {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
rustfs_utils::http::insert_str(
|
||||||
|
&mut metadata,
|
||||||
|
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||||
|
"klauspost/compress/s2".to_string(),
|
||||||
|
);
|
||||||
|
let corrupt_object = ObjectInfo {
|
||||||
|
size: 400,
|
||||||
|
actual_size: -2,
|
||||||
|
user_defined: Arc::new(metadata.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(matches!(quota_object_size(&corrupt_object), Err(Error::PartMissingOrCorrupt)));
|
||||||
|
|
||||||
|
let corrupt_part = ObjectInfo {
|
||||||
|
size: 400,
|
||||||
|
user_defined: Arc::new(metadata),
|
||||||
|
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
||||||
|
size: 400,
|
||||||
|
actual_size: -2,
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(matches!(quota_object_size(&corrupt_part), Err(Error::PartMissingOrCorrupt)));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() {
|
async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() {
|
||||||
|
|||||||
@@ -689,6 +689,9 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_actual_size(&self) -> std::io::Result<i64> {
|
pub fn get_actual_size(&self) -> std::io::Result<i64> {
|
||||||
|
if self.actual_size < -1 || (self.actual_size == -1 && !self.is_compressed()) {
|
||||||
|
return Err(std::io::Error::other("invalid negative actual size"));
|
||||||
|
}
|
||||||
if self.actual_size > 0 {
|
if self.actual_size > 0 {
|
||||||
return Ok(self.actual_size);
|
return Ok(self.actual_size);
|
||||||
}
|
}
|
||||||
@@ -700,10 +703,25 @@ impl ObjectInfo {
|
|||||||
let size = size_str.parse::<i64>().map_err(|e| std::io::Error::other(e.to_string()))?;
|
let size = size_str.parse::<i64>().map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
return Ok(size);
|
return Ok(size);
|
||||||
}
|
}
|
||||||
let mut actual_size = 0;
|
if self.actual_size == -1 && self.parts.is_empty() {
|
||||||
self.parts.iter().for_each(|part| {
|
return Ok(-1);
|
||||||
actual_size += part.actual_size;
|
}
|
||||||
});
|
let mut actual_size = 0_i64;
|
||||||
|
let mut unknown = false;
|
||||||
|
for part in self.parts.iter() {
|
||||||
|
match part.actual_size {
|
||||||
|
-1 => unknown = true,
|
||||||
|
size if size >= 0 => {
|
||||||
|
actual_size = actual_size
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| std::io::Error::other("compressed actual size overflow"))?;
|
||||||
|
}
|
||||||
|
_ => return Err(std::io::Error::other("invalid negative compressed part size")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unknown {
|
||||||
|
return Ok(-1);
|
||||||
|
}
|
||||||
if actual_size == 0 && actual_size != self.size {
|
if actual_size == 0 && actual_size != self.size {
|
||||||
return Err(std::io::Error::other(format!("invalid decompressed size {} {}", actual_size, self.size)));
|
return Err(std::io::Error::other(format!("invalid decompressed size {} {}", actual_size, self.size)));
|
||||||
}
|
}
|
||||||
@@ -718,6 +736,18 @@ impl ObjectInfo {
|
|||||||
Ok(self.size)
|
Ok(self.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a non-negative size for client and replication boundaries.
|
||||||
|
///
|
||||||
|
/// Compressed legacy metadata can retain the internal `-1` unknown-size
|
||||||
|
/// sentinel. Those boundaries cannot emit a negative length, so they use
|
||||||
|
/// the persisted physical size while quota accounting keeps the sentinel
|
||||||
|
/// distinction in [`crate::data_usage::quota_object_size`].
|
||||||
|
pub fn get_actual_size_or_physical(&self) -> i64 {
|
||||||
|
self.get_actual_size()
|
||||||
|
.map(|size| if size >= 0 { size } else { self.size.max(0) })
|
||||||
|
.unwrap_or_else(|_| self.size.max(0))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_file_info(fi: &FileInfo, bucket: &str, object: &str, versioned: bool) -> ObjectInfo {
|
pub fn from_file_info(fi: &FileInfo, bucket: &str, object: &str, versioned: bool) -> ObjectInfo {
|
||||||
let mut version_id = fi.version_id;
|
let mut version_id = fi.version_id;
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ use crate::storage_api_contracts::{
|
|||||||
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations as _, MultipartUploadResult, PartInfo,
|
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations as _, MultipartUploadResult, PartInfo,
|
||||||
},
|
},
|
||||||
namespace::NamespaceLocking as _,
|
namespace::NamespaceLocking as _,
|
||||||
object::{DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
|
object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
|
||||||
range::HTTPRangeSpec,
|
range::HTTPRangeSpec,
|
||||||
};
|
};
|
||||||
use crate::store::utils::is_reserved_or_invalid_bucket;
|
use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ use crate::bucket::replication::{
|
|||||||
DeleteReplicationConfigSnapshot, ReplicationLifecycleBridge, ReplicationStatusType, VersionPurgeStatusType,
|
DeleteReplicationConfigSnapshot, ReplicationLifecycleBridge, ReplicationStatusType, VersionPurgeStatusType,
|
||||||
replication_state_to_filemeta, replication_status_from_filemeta, version_purge_status_to_filemeta,
|
replication_state_to_filemeta, replication_status_from_filemeta, version_purge_status_to_filemeta,
|
||||||
};
|
};
|
||||||
|
use crate::data_usage::quota_object_size;
|
||||||
use crate::diagnostics::get::GetObjectFailureReason;
|
use crate::diagnostics::get::GetObjectFailureReason;
|
||||||
use crate::disk::{DataDirDeleteStatus, OldCurrentSize};
|
use crate::disk::{DataDirDeleteStatus, OldCurrentSize};
|
||||||
use crate::error::is_err_invalid_upload_id;
|
use crate::error::is_err_invalid_upload_id;
|
||||||
@@ -5655,7 +5656,18 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
objects: Vec<ObjectToDelete>,
|
objects: Vec<ObjectToDelete>,
|
||||||
opts: ObjectOptions,
|
opts: ObjectOptions,
|
||||||
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
|
||||||
|
let (deleted, errors, _) = self.delete_objects_with_accounting(bucket, objects, opts).await;
|
||||||
|
(deleted, errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_objects_with_accounting(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
objects: Vec<ObjectToDelete>,
|
||||||
|
opts: ObjectOptions,
|
||||||
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||||
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
||||||
|
let mut accounting = vec![None; objects.len()];
|
||||||
let delete_config_snapshot = opts
|
let delete_config_snapshot = opts
|
||||||
.delete_replication_config_snapshot
|
.delete_replication_config_snapshot
|
||||||
.clone()
|
.clone()
|
||||||
@@ -5745,7 +5757,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
*item = Some(Error::other(message.clone()));
|
*item = Some(Error::other(message.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (del_objects, del_errs);
|
return (del_objects, del_errs, accounting);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -5792,6 +5804,22 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
let source_missing = gerr
|
let source_missing = gerr
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|err| is_err_object_not_found(err) || is_err_version_not_found(err));
|
.is_some_and(|err| is_err_object_not_found(err) || is_err_version_not_found(err));
|
||||||
|
// Resolve accounting from the generation selected under this
|
||||||
|
// object's write lock. A request-layer pre-stat is only an
|
||||||
|
// optimization and cannot identify a concurrent overwrite.
|
||||||
|
let (accounting_size, accounting_version_id, removed_current_object) = if source_missing
|
||||||
|
|| dobj.synthetic_version_id
|
||||||
|
|| set_disk_delete_creates_delete_marker(&check_opts)
|
||||||
|
|| goi.delete_marker
|
||||||
|
{
|
||||||
|
(None, None, false)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
quota_object_size(&goi).ok(),
|
||||||
|
goi.version_id.filter(|version_id| !version_id.is_nil()),
|
||||||
|
(dobj.version_id.is_none() || is_explicit_null_version(dobj.version_id)) && !dobj.synthetic_version_id,
|
||||||
|
)
|
||||||
|
};
|
||||||
// Normalize both sides before comparing. `goi.version_id` is the
|
// Normalize both sides before comparing. `goi.version_id` is the
|
||||||
// client-facing identity, where `from_file_info` synthesizes
|
// client-facing identity, where `from_file_info` synthesizes
|
||||||
// `Some(Uuid::nil())` for a null version on a versioned or
|
// `Some(Uuid::nil())` for a null version on a versioned or
|
||||||
@@ -5920,7 +5948,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
},
|
},
|
||||||
replication_state: vr.replication_state_internal.clone(),
|
replication_state: vr.replication_state_internal.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
};
|
||||||
|
accounting[i] = Some(DeleteAccounting {
|
||||||
|
size: accounting_size,
|
||||||
|
version_id: accounting_version_id,
|
||||||
|
removed_current_object,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only add to vers_map if we hold the lock
|
// Only add to vers_map if we hold the lock
|
||||||
@@ -5966,7 +5999,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (del_objects, del_errs);
|
return (del_objects, del_errs, accounting);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut persisted_journal_entries = Vec::with_capacity(journal_entries.len());
|
let mut persisted_journal_entries = Vec::with_capacity(journal_entries.len());
|
||||||
@@ -6204,7 +6237,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(del_objects, del_errs)
|
// An accounting identity is actionable only when the delete result is
|
||||||
|
// successful. Never let a failed commit (including a partial quorum
|
||||||
|
// failure) reach the request-layer fast delta path.
|
||||||
|
for (index, err) in del_errs.iter().enumerate() {
|
||||||
|
if err.is_some() {
|
||||||
|
accounting[index] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(del_objects, del_errs, accounting)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self))]
|
||||||
@@ -6533,6 +6575,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
|
|
||||||
let mut obj_info = ObjectInfo::from_file_info(&dfi, bucket, object, opts.versioned || opts.version_suspended);
|
let mut obj_info = ObjectInfo::from_file_info(&dfi, bucket, object, opts.versioned || opts.version_suspended);
|
||||||
obj_info.size = goi.size;
|
obj_info.size = goi.size;
|
||||||
|
// Keep the committed source metadata on the internal delete result so
|
||||||
|
// the request layer can derive canonical accounting for this exact
|
||||||
|
// generation. Delete responses do not expose these fields.
|
||||||
|
obj_info.actual_size = goi.actual_size;
|
||||||
|
obj_info.user_defined = Arc::clone(&goi.user_defined);
|
||||||
|
obj_info.parts = Arc::clone(&goi.parts);
|
||||||
obj_info.user_tags = Arc::clone(&goi.user_tags);
|
obj_info.user_tags = Arc::clone(&goi.user_tags);
|
||||||
self.invalidate_get_object_metadata_cache(bucket, object).await;
|
self.invalidate_get_object_metadata_cache(bucket, object).await;
|
||||||
Ok(obj_info)
|
Ok(obj_info)
|
||||||
@@ -7824,6 +7872,113 @@ mod replication_quota_safety_tests {
|
|||||||
assert_eq!(stored.get_actual_size().expect("stored logical size should parse"), 1);
|
assert_eq!(stored.get_actual_size().expect("stored logical size should parse"), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_returns_canonical_compressed_accounting_size() {
|
||||||
|
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
let bucket = "compressed-delete-accounting";
|
||||||
|
for disk in &disks {
|
||||||
|
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user_defined = HashMap::new();
|
||||||
|
insert_str(
|
||||||
|
&mut user_defined,
|
||||||
|
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||||
|
"klauspost/compress/s2".to_string(),
|
||||||
|
);
|
||||||
|
insert_str(&mut user_defined, SUFFIX_ACTUAL_SIZE, "1000".to_string());
|
||||||
|
let mut reader = PutObjReader::new(
|
||||||
|
HashReader::from_stream(Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false)
|
||||||
|
.expect("compressed fixture reader should be valid"),
|
||||||
|
);
|
||||||
|
set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
"object",
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
user_defined,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("compressed object should be written");
|
||||||
|
|
||||||
|
let (deleted, errors, accounting) = set_disks
|
||||||
|
.delete_objects_with_accounting(
|
||||||
|
bucket,
|
||||||
|
vec![ObjectToDelete {
|
||||||
|
object_name: "object".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
ObjectOptions {
|
||||||
|
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
|
||||||
|
ObjectLockConfigState::ConfirmedAbsent,
|
||||||
|
))),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(errors[0].is_none(), "compressed delete should succeed: {:?}", errors[0]);
|
||||||
|
assert!(deleted[0].found, "the committed object must be reported as found");
|
||||||
|
assert_eq!(accounting[0].as_ref().and_then(|value| value.size), Some(1000));
|
||||||
|
assert!(accounting[0].as_ref().is_some_and(|value| value.version_id.is_none()));
|
||||||
|
assert!(accounting[0].as_ref().is_some_and(|value| value.removed_current_object));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn suspended_delete_marker_does_not_return_body_accounting() {
|
||||||
|
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
let bucket = "suspended-delete-accounting";
|
||||||
|
for disk in &disks {
|
||||||
|
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user_defined = HashMap::new();
|
||||||
|
insert_str(
|
||||||
|
&mut user_defined,
|
||||||
|
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||||
|
"klauspost/compress/s2".to_string(),
|
||||||
|
);
|
||||||
|
insert_str(&mut user_defined, SUFFIX_ACTUAL_SIZE, "1000".to_string());
|
||||||
|
let mut reader = PutObjReader::new(
|
||||||
|
HashReader::from_stream(Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false)
|
||||||
|
.expect("compressed fixture reader should be valid"),
|
||||||
|
);
|
||||||
|
let suspended_opts = ObjectOptions {
|
||||||
|
version_suspended: true,
|
||||||
|
delete_replication_config_snapshot: Some(Arc::new(DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||||
|
s3s::dto::VersioningConfiguration {
|
||||||
|
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::SUSPENDED)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
))),
|
||||||
|
user_defined,
|
||||||
|
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(ObjectLockConfigState::ConfirmedAbsent))),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
set_disks
|
||||||
|
.put_object(bucket, "object", &mut reader, &suspended_opts)
|
||||||
|
.await
|
||||||
|
.expect("compressed object should be written");
|
||||||
|
|
||||||
|
let (deleted, errors, accounting) = set_disks
|
||||||
|
.delete_objects_with_accounting(
|
||||||
|
bucket,
|
||||||
|
vec![ObjectToDelete {
|
||||||
|
object_name: "object".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
suspended_opts,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(errors[0].is_none(), "suspended delete should create a marker: {:?}", errors[0]);
|
||||||
|
assert!(deleted[0].delete_marker);
|
||||||
|
assert!(accounting[0].is_none(), "a delete marker must not carry body accounting");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn direct_put_cannot_persist_a_tiny_logical_size() {
|
async fn direct_put_cannot_persist_a_tiny_logical_size() {
|
||||||
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ pub(crate) mod object {
|
|||||||
use super::{Debug, Error, FileInfo, GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
use super::{Debug, Error, FileInfo, GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||||
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||||
pub(crate) use rustfs_storage_api::{
|
pub(crate) use rustfs_storage_api::{
|
||||||
DeletedObject, HTTPPreconditions, ObjectIO, ObjectLockDeleteOptions, ObjectLockRetentionOptions, ObjectOperations,
|
DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO, ObjectLockDeleteOptions, ObjectLockRetentionOptions,
|
||||||
ObjectPreconditionError, ObjectPreconditionPart, ObjectPreconditionState, ObjectToDelete,
|
ObjectOperations, ObjectPreconditionError, ObjectPreconditionPart, ObjectPreconditionState, ObjectToDelete,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) trait EcstoreObjectIO:
|
pub(crate) trait EcstoreObjectIO:
|
||||||
|
|||||||
@@ -297,10 +297,16 @@ impl ECStore {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::bucket::metadata_sys;
|
||||||
use crate::core::pools::{PoolDecommissionInfo, PoolStatus};
|
use crate::core::pools::{PoolDecommissionInfo, PoolStatus};
|
||||||
use crate::disk::{DiskOption, format::FormatV3, new_disk};
|
use crate::disk::{DeleteOptions, DiskOption, format::FormatV3, new_disk};
|
||||||
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||||
|
use crate::runtime::instance::InstanceContext;
|
||||||
|
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
|
||||||
|
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations};
|
||||||
use crate::store::init_format::{load_format_erasure, save_format_file};
|
use crate::store::init_format::{load_format_erasure, save_format_file};
|
||||||
|
use crate::store::init_local_disks_with_instance_ctx;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
|
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
|
||||||
let format = FormatV3::new(1, 1);
|
let format = FormatV3::new(1, 1);
|
||||||
@@ -347,6 +353,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn multi_pool_heal_store() -> (tempfile::TempDir, Arc<ECStore>, CancellationToken) {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("multi-pool heal test directory should be created");
|
||||||
|
let mut pool_endpoints = Vec::new();
|
||||||
|
for pool_index in 0..2 {
|
||||||
|
let mut endpoints = Vec::new();
|
||||||
|
for disk_index in 0..4 {
|
||||||
|
let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}"));
|
||||||
|
tokio::fs::create_dir_all(&disk_path)
|
||||||
|
.await
|
||||||
|
.expect("multi-pool heal test disk should be created");
|
||||||
|
let mut endpoint = Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8"))
|
||||||
|
.expect("test endpoint should parse");
|
||||||
|
endpoint.set_pool_index(pool_index);
|
||||||
|
endpoint.set_set_index(0);
|
||||||
|
endpoint.set_disk_index(disk_index);
|
||||||
|
endpoints.push(endpoint);
|
||||||
|
}
|
||||||
|
pool_endpoints.push(PoolEndpoints {
|
||||||
|
legacy: false,
|
||||||
|
set_count: 1,
|
||||||
|
drives_per_set: 4,
|
||||||
|
endpoints: Endpoints::from(endpoints),
|
||||||
|
cmd_line: format!("heal-owner-pool-{pool_index}"),
|
||||||
|
platform: "test".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let endpoint_pools = EndpointServerPools::from(pool_endpoints);
|
||||||
|
let instance_ctx = Arc::new(InstanceContext::new());
|
||||||
|
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("multi-pool local disks should initialize");
|
||||||
|
let shutdown = CancellationToken::new();
|
||||||
|
let store = ECStore::new_with_instance_ctx(
|
||||||
|
"127.0.0.1:0".parse().expect("test address should parse"),
|
||||||
|
endpoint_pools,
|
||||||
|
shutdown.clone(),
|
||||||
|
instance_ctx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("multi-pool test store should initialize");
|
||||||
|
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
(temp_dir, store, shutdown)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn heal_object_pool_scope_selects_only_requested_pool() {
|
async fn heal_object_pool_scope_selects_only_requested_pool() {
|
||||||
let store = minimal_heal_store().await;
|
let store = minimal_heal_store().await;
|
||||||
@@ -506,6 +557,229 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn unscoped_heal_object_suspended_owner_semantics() {
|
||||||
|
let (_temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||||
|
let bucket = format!("heal-owner-{}", Uuid::new_v4().simple());
|
||||||
|
let active_object = "active-owner";
|
||||||
|
let suspended_only_object = "suspended-only";
|
||||||
|
let duplicate_object = "duplicate-owner";
|
||||||
|
let marker_object = "marker-owner";
|
||||||
|
let quorum_object = "quorum-owner";
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket should be created in all pools");
|
||||||
|
|
||||||
|
let mut active_reader = PutObjReader::from_vec(b"active owner".to_vec());
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(&bucket, active_object, &mut active_reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("active owner object should be written");
|
||||||
|
let active_disks = store.pools[0].disk_set[0].disks.read().await.clone();
|
||||||
|
let missing_active_disk = active_disks[0].clone().expect("active disk should be online");
|
||||||
|
missing_active_disk
|
||||||
|
.delete(
|
||||||
|
&bucket,
|
||||||
|
active_object,
|
||||||
|
DeleteOptions {
|
||||||
|
recursive: true,
|
||||||
|
immediate: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("active owner shard should be removed for repair");
|
||||||
|
assert!(
|
||||||
|
missing_active_disk.read_xl(&bucket, active_object, false).await.is_err(),
|
||||||
|
"the active owner fixture must start with one missing metadata copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut suspended_reader = PutObjReader::from_vec(b"suspended owner".to_vec());
|
||||||
|
store.pools[1]
|
||||||
|
.put_object(&bucket, suspended_only_object, &mut suspended_reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("suspended owner object should be written");
|
||||||
|
for (pool_index, mod_time) in [1_i64, 2_i64].into_iter().enumerate() {
|
||||||
|
let mut duplicate_reader = PutObjReader::from_vec(format!("duplicate-pool-{pool_index}").into_bytes());
|
||||||
|
store.pools[pool_index]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
duplicate_object,
|
||||||
|
&mut duplicate_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(mod_time)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("duplicate owner object should be written");
|
||||||
|
}
|
||||||
|
let duplicate_missing_disk = store.pools[0].disk_set[0].disks.read().await[0]
|
||||||
|
.clone()
|
||||||
|
.expect("duplicate active owner disk should be online");
|
||||||
|
duplicate_missing_disk
|
||||||
|
.delete(
|
||||||
|
&bucket,
|
||||||
|
duplicate_object,
|
||||||
|
DeleteOptions {
|
||||||
|
recursive: true,
|
||||||
|
immediate: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("duplicate active owner shard should be removed for repair");
|
||||||
|
let history_version = Uuid::new_v4();
|
||||||
|
let mut history_reader = PutObjReader::from_vec(b"marker history".to_vec());
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
marker_object,
|
||||||
|
&mut history_reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
version_id: Some(history_version.to_string()),
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("versioned marker history should be written");
|
||||||
|
store.pools[0]
|
||||||
|
.delete_object(
|
||||||
|
&bucket,
|
||||||
|
marker_object,
|
||||||
|
ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(2)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("delete marker should be written");
|
||||||
|
let mut quorum_reader = PutObjReader::from_vec(b"quorum boundary".to_vec());
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(&bucket, quorum_object, &mut quorum_reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("quorum boundary object should be written");
|
||||||
|
{
|
||||||
|
let mut pool_meta = store.pool_meta.write().await;
|
||||||
|
let mut next = PoolMeta::new(&store.pools, &pool_meta);
|
||||||
|
next.pools[1].decommission = Some(PoolDecommissionInfo {
|
||||||
|
start_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
*pool_meta = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, duplicate_owner) = store
|
||||||
|
.get_latest_object_info_with_idx(&bucket, duplicate_object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("duplicate owner should resolve");
|
||||||
|
assert_eq!(duplicate_owner, 1, "latest duplicate must win when all pools are eligible");
|
||||||
|
let (_, active_duplicate_owner) = store
|
||||||
|
.get_latest_object_info_with_idx(
|
||||||
|
&bucket,
|
||||||
|
duplicate_object,
|
||||||
|
&ObjectOptions {
|
||||||
|
skip_decommissioned: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("active duplicate owner should resolve");
|
||||||
|
assert_eq!(
|
||||||
|
active_duplicate_owner, 0,
|
||||||
|
"suspended duplicate must be excluded from active owner selection"
|
||||||
|
);
|
||||||
|
let (duplicate_result, duplicate_err) = store
|
||||||
|
.handle_heal_object(&bucket, duplicate_object, "", &HealOpts::default())
|
||||||
|
.await
|
||||||
|
.expect("duplicate owner heal should complete through the production path");
|
||||||
|
assert_eq!(duplicate_result.object, duplicate_object);
|
||||||
|
assert!(duplicate_err.is_none(), "active duplicate should be repaired: {duplicate_err:?}");
|
||||||
|
assert!(
|
||||||
|
duplicate_missing_disk.read_xl(&bucket, duplicate_object, false).await.is_ok(),
|
||||||
|
"production heal must repair the active duplicate owner rather than the suspended owner"
|
||||||
|
);
|
||||||
|
let (marker_info, marker_owner) = store
|
||||||
|
.get_latest_object_info_with_idx(
|
||||||
|
&bucket,
|
||||||
|
marker_object,
|
||||||
|
&ObjectOptions {
|
||||||
|
skip_decommissioned: true,
|
||||||
|
versioned: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("latest delete marker should resolve");
|
||||||
|
assert_eq!(marker_owner, 0);
|
||||||
|
assert!(marker_info.delete_marker, "latest version must preserve delete-marker semantics");
|
||||||
|
|
||||||
|
let (active_result, active_err) = store
|
||||||
|
.handle_heal_object(&bucket, active_object, "", &HealOpts::default())
|
||||||
|
.await
|
||||||
|
.expect("unscoped active-owner heal should complete");
|
||||||
|
assert_eq!(active_result.object, active_object);
|
||||||
|
assert!(active_err.is_none(), "active owner must be selected even with a suspended pool");
|
||||||
|
assert!(
|
||||||
|
missing_active_disk.read_xl(&bucket, active_object, false).await.is_ok(),
|
||||||
|
"active owner heal must write the missing disk metadata: result={active_result:?}, err={active_err:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store.pools[1]
|
||||||
|
.get_object_info(&bucket, active_object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"the suspended pool must not be written for an active-owner object"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (suspended_result, suspended_err) = store
|
||||||
|
.handle_heal_object(&bucket, suspended_only_object, "", &HealOpts::default())
|
||||||
|
.await
|
||||||
|
.expect("unscoped suspended-only heal should return a terminal result");
|
||||||
|
assert!(suspended_result.object.is_empty());
|
||||||
|
assert!(matches!(suspended_err, Some(Error::FileNotFound)));
|
||||||
|
assert!(
|
||||||
|
store.pools[1]
|
||||||
|
.get_object_info(&bucket, suspended_only_object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.is_ok(),
|
||||||
|
"suspended-only data must remain untouched when unscoped heal reports absent"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, explicit_err) = store
|
||||||
|
.handle_heal_object(
|
||||||
|
&bucket,
|
||||||
|
suspended_only_object,
|
||||||
|
"",
|
||||||
|
&HealOpts {
|
||||||
|
pool: Some(1),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("explicit suspended-owner heal should return a mapped error");
|
||||||
|
assert!(matches!(explicit_err, Some(Error::SlowDown)));
|
||||||
|
|
||||||
|
let original_quorum_disks = store.pools[0].disk_set[0].disks.read().await.clone();
|
||||||
|
let surviving_quorum_disk = original_quorum_disks[3].clone();
|
||||||
|
*store.pools[0].disk_set[0].disks.write().await = vec![None, None, None, surviving_quorum_disk];
|
||||||
|
let (_, quorum_err) = store
|
||||||
|
.handle_heal_object(&bucket, quorum_object, "", &HealOpts::default())
|
||||||
|
.await
|
||||||
|
.expect("quorum boundary heal should return a mapped result");
|
||||||
|
*store.pools[0].disk_set[0].disks.write().await = original_quorum_disks;
|
||||||
|
assert!(
|
||||||
|
matches!(quorum_err, Some(Error::ErasureReadQuorum)),
|
||||||
|
"quorum-boundary heal must preserve quorum error, got {quorum_err:?}"
|
||||||
|
);
|
||||||
|
shutdown.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn handle_heal_format_continues_after_a_pool_error() {
|
async fn handle_heal_format_continues_after_a_pool_error() {
|
||||||
let canonical_format = FormatV3::new(1, 3);
|
let canonical_format = FormatV3::new(1, 3);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use crate::set_disk::{
|
|||||||
};
|
};
|
||||||
use crate::storage_api_contracts::{
|
use crate::storage_api_contracts::{
|
||||||
namespace::NamespaceLocking as _,
|
namespace::NamespaceLocking as _,
|
||||||
object::{ObjectIO as _, ObjectOperations as _},
|
object::{DeleteAccounting, ObjectIO as _, ObjectOperations as _},
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex as ParkingMutex;
|
use parking_lot::Mutex as ParkingMutex;
|
||||||
use rustfs_io_metrics::{
|
use rustfs_io_metrics::{
|
||||||
@@ -1216,6 +1216,14 @@ fn return_batch_delete_lock_error(objects: &[ObjectToDelete], err: Error) -> (Ve
|
|||||||
(del_objects, del_errs)
|
(del_objects, del_errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn return_batch_delete_lock_error_with_accounting(
|
||||||
|
objects: &[ObjectToDelete],
|
||||||
|
err: Error,
|
||||||
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||||
|
let (deleted, errors) = return_batch_delete_lock_error(objects, err);
|
||||||
|
(deleted, errors, vec![None; objects.len()])
|
||||||
|
}
|
||||||
|
|
||||||
fn sorted_unique_delete_object_names(objects: &[ObjectToDelete]) -> Vec<&str> {
|
fn sorted_unique_delete_object_names(objects: &[ObjectToDelete]) -> Vec<&str> {
|
||||||
let mut object_names: Vec<&str> = objects.iter().map(|object| object.object_name.as_str()).collect();
|
let mut object_names: Vec<&str> = objects.iter().map(|object| object.object_name.as_str()).collect();
|
||||||
object_names.sort_unstable();
|
object_names.sort_unstable();
|
||||||
@@ -2312,6 +2320,22 @@ impl ECStore {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_objects_with_tier_delete_journal_and_accounting(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
bucket: &str,
|
||||||
|
objects: Vec<ObjectToDelete>,
|
||||||
|
opts: ObjectOptions,
|
||||||
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||||
|
let result = self
|
||||||
|
.handle_delete_objects_with_journal_and_accounting(bucket, objects, opts, Some(Arc::clone(self)))
|
||||||
|
.await;
|
||||||
|
let success_count = result.1.iter().filter(|err| err.is_none()).count();
|
||||||
|
if success_count > 0 {
|
||||||
|
list_objects::observe_list_objects_mutations(self, bucket, success_count).await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub(super) async fn handle_delete_object(&self, bucket: &str, object: &str, opts: ObjectOptions) -> Result<ObjectInfo> {
|
pub(super) async fn handle_delete_object(&self, bucket: &str, object: &str, opts: ObjectOptions) -> Result<ObjectInfo> {
|
||||||
self.handle_delete_object_with_journal(bucket, object, opts, None).await
|
self.handle_delete_object_with_journal(bucket, object, opts, None).await
|
||||||
@@ -2689,6 +2713,19 @@ impl ECStore {
|
|||||||
opts: ObjectOptions,
|
opts: ObjectOptions,
|
||||||
tier_journal_api: Option<Arc<ECStore>>,
|
tier_journal_api: Option<Arc<ECStore>>,
|
||||||
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>) {
|
||||||
|
let (deleted, errors, _) = self
|
||||||
|
.handle_delete_objects_with_journal_and_accounting(bucket, objects, opts, tier_journal_api)
|
||||||
|
.await;
|
||||||
|
(deleted, errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_delete_objects_with_journal_and_accounting(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
objects: Vec<ObjectToDelete>,
|
||||||
|
opts: ObjectOptions,
|
||||||
|
tier_journal_api: Option<Arc<ECStore>>,
|
||||||
|
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||||
// encode object name
|
// encode object name
|
||||||
let objects: Vec<ObjectToDelete> = objects
|
let objects: Vec<ObjectToDelete> = objects
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2701,6 +2738,7 @@ impl ECStore {
|
|||||||
|
|
||||||
// Default return value
|
// Default return value
|
||||||
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
let mut del_objects = vec![DeletedObject::default(); objects.len()];
|
||||||
|
let mut accounting = vec![None; objects.len()];
|
||||||
|
|
||||||
let mut del_errs = Vec::with_capacity(objects.len());
|
let mut del_errs = Vec::with_capacity(objects.len());
|
||||||
for _ in 0..objects.len() {
|
for _ in 0..objects.len() {
|
||||||
@@ -2714,7 +2752,7 @@ impl ECStore {
|
|||||||
} else {
|
} else {
|
||||||
match self.acquire_bucket_lifecycle_read_lock(bucket).await {
|
match self.acquire_bucket_lifecycle_read_lock(bucket).await {
|
||||||
Ok(guard) => Some(guard),
|
Ok(guard) => Some(guard),
|
||||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||||
@@ -2726,21 +2764,21 @@ impl ECStore {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
let errors = (0..objects.len()).map(|_| Some(Error::other(message.clone()))).collect();
|
let errors = (0..objects.len()).map(|_| Some(Error::other(message.clone()))).collect();
|
||||||
return (del_objects, errors);
|
return (del_objects, errors, accounting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !is_meta_bucketname(bucket)
|
if !is_meta_bucketname(bucket)
|
||||||
&& let Err(err) = get_cached_bucket_incarnation_id_in(&self.ctx, bucket).await
|
&& let Err(err) = get_cached_bucket_incarnation_id_in(&self.ctx, bucket).await
|
||||||
{
|
{
|
||||||
return return_batch_delete_lock_error(objects.as_slice(), err);
|
return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err);
|
||||||
}
|
}
|
||||||
let _object_lock_metadata_guard = if is_meta_bucketname(bucket) {
|
let _object_lock_metadata_guard = if is_meta_bucketname(bucket) {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(match acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await {
|
Some(match acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket).await {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
if let Some(guard) = _object_lock_metadata_guard.as_ref() {
|
if let Some(guard) = _object_lock_metadata_guard.as_ref() {
|
||||||
@@ -2750,7 +2788,7 @@ impl ECStore {
|
|||||||
let (state, incarnation_id, config_revision) =
|
let (state, incarnation_id, config_revision) =
|
||||||
match get_object_lock_config_and_incarnation_from_disk_in(&self.ctx, bucket).await {
|
match get_object_lock_config_and_incarnation_from_disk_in(&self.ctx, bucket).await {
|
||||||
Ok(snapshot) => snapshot,
|
Ok(snapshot) => snapshot,
|
||||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||||
};
|
};
|
||||||
opts.object_lock_config_snapshot = Some(Arc::new(ObjectLockConfigSnapshot::for_store_bucket(
|
opts.object_lock_config_snapshot = Some(Arc::new(ObjectLockConfigSnapshot::for_store_bucket(
|
||||||
self.id,
|
self.id,
|
||||||
@@ -2766,7 +2804,10 @@ impl ECStore {
|
|||||||
if let (Some(expected), Some(current)) = (opts.expected_bucket_incarnation_id, current_bucket_incarnation_id)
|
if let (Some(expected), Some(current)) = (opts.expected_bucket_incarnation_id, current_bucket_incarnation_id)
|
||||||
&& expected != current
|
&& expected != current
|
||||||
{
|
{
|
||||||
return return_batch_delete_lock_error(objects.as_slice(), StorageError::BucketNotFound(bucket.to_string()));
|
return return_batch_delete_lock_error_with_accounting(
|
||||||
|
objects.as_slice(),
|
||||||
|
StorageError::BucketNotFound(bucket.to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if current_bucket_incarnation_id.is_some() {
|
if current_bucket_incarnation_id.is_some() {
|
||||||
@@ -2774,7 +2815,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
let _object_lock_guards = match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await {
|
let _object_lock_guards = match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await {
|
||||||
Ok(guards) => guards,
|
Ok(guards) => guards,
|
||||||
Err(err) => return return_batch_delete_lock_error(objects.as_slice(), err),
|
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut futures = Vec::with_capacity(self.pools.len());
|
let mut futures = Vec::with_capacity(self.pools.len());
|
||||||
@@ -2783,22 +2824,24 @@ impl ECStore {
|
|||||||
if self.is_pool_rebalancing(pool.pool_idx).await {
|
if self.is_pool_rebalancing(pool.pool_idx).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
futures.push(pool.delete_objects(bucket, objects.clone(), opts.clone()));
|
futures.push(pool.delete_objects_with_accounting(bucket, objects.clone(), opts.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = join_all(futures).await;
|
let results = join_all(futures).await;
|
||||||
|
|
||||||
for idx in 0..del_objects.len() {
|
for idx in 0..del_objects.len() {
|
||||||
for (dels, errs) in results.iter() {
|
for (dels, errs, pool_accounting) in results.iter() {
|
||||||
if errs[idx].is_none() && dels[idx].found {
|
if errs[idx].is_none() && dels[idx].found {
|
||||||
del_errs[idx] = None;
|
del_errs[idx] = None;
|
||||||
del_objects[idx] = dels[idx].clone();
|
del_objects[idx] = dels[idx].clone();
|
||||||
|
accounting[idx] = pool_accounting[idx].clone();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if del_errs[idx].is_none() {
|
if del_errs[idx].is_none() {
|
||||||
del_errs[idx] = errs[idx].clone();
|
del_errs[idx] = errs[idx].clone();
|
||||||
del_objects[idx] = dels[idx].clone();
|
del_objects[idx] = dels[idx].clone();
|
||||||
|
accounting[idx] = pool_accounting[idx].clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2807,7 +2850,7 @@ impl ECStore {
|
|||||||
v.object_name = decode_dir_object(&v.object_name);
|
v.object_name = decode_dir_object(&v.object_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
(del_objects, del_errs)
|
(del_objects, del_errs, accounting)
|
||||||
|
|
||||||
// let mut futures = Vec::with_capacity(objects.len());
|
// let mut futures = Vec::with_capacity(objects.len());
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::heal::{
|
use crate::heal::{
|
||||||
progress::HealProgress,
|
progress::{HealProgress, add_bytes, increment_counter},
|
||||||
resume::{
|
resume::{
|
||||||
CheckpointManager, ReplacementTargetIdentity, ResumeManager, ResumeUtils, compose_key,
|
CheckpointManager, CheckpointObjectOutcome, ReplacementTargetIdentity, ResumeManager, ResumeUtils, compose_key,
|
||||||
replacement_target_identities_match,
|
replacement_target_identities_match,
|
||||||
},
|
},
|
||||||
storage::{HealStorageAPI, next_heal_listing_token},
|
storage::{HealStorageAPI, next_heal_listing_token},
|
||||||
@@ -410,6 +410,9 @@ impl ErasureSetHealer {
|
|||||||
&& state.successful_objects == 0
|
&& state.successful_objects == 0
|
||||||
&& state.failed_objects == 0
|
&& state.failed_objects == 0
|
||||||
&& state.skipped_objects == 0
|
&& state.skipped_objects == 0
|
||||||
|
&& state.skipped_new_versions == 0
|
||||||
|
&& state.skipped_ilm_expired == 0
|
||||||
|
&& state.processed_bytes == 0
|
||||||
{
|
{
|
||||||
// schedule_retry persists the authoritative resume reset before
|
// schedule_retry persists the authoritative resume reset before
|
||||||
// resetting the checkpoint. Reapply the checkpoint reset after
|
// resetting the checkpoint. Reapply the checkpoint reset after
|
||||||
@@ -474,6 +477,23 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
// 2. initialize progress
|
// 2. initialize progress
|
||||||
self.initialize_progress(buckets, &state).await;
|
self.initialize_progress(buckets, &state).await;
|
||||||
|
let (baseline_known, baseline_count, baseline_size, baseline_generation) = {
|
||||||
|
let baseline = self.progress.read().await;
|
||||||
|
(
|
||||||
|
baseline.baseline_known,
|
||||||
|
baseline.objects_total_count,
|
||||||
|
baseline.objects_total_size,
|
||||||
|
baseline.baseline_generation,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if baseline_known {
|
||||||
|
resume_manager
|
||||||
|
.set_progress_baseline(baseline_count, baseline_size, baseline_generation)
|
||||||
|
.await?;
|
||||||
|
checkpoint_manager
|
||||||
|
.set_progress_baseline(baseline_count, baseline_size, baseline_generation)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. continue from checkpoint
|
// 3. continue from checkpoint
|
||||||
let current_bucket_index = checkpoint.current_bucket_index;
|
let current_bucket_index = checkpoint.current_bucket_index;
|
||||||
@@ -483,12 +503,66 @@ impl ErasureSetHealer {
|
|||||||
let mut successful_objects = state.successful_objects;
|
let mut successful_objects = state.successful_objects;
|
||||||
let mut failed_objects = state.failed_objects;
|
let mut failed_objects = state.failed_objects;
|
||||||
let mut skipped_objects = state.skipped_objects;
|
let mut skipped_objects = state.skipped_objects;
|
||||||
|
let checkpoint_has_progress = checkpoint.baseline_known
|
||||||
|
|| checkpoint.successful_objects > 0
|
||||||
|
|| checkpoint.failed_object_count > 0
|
||||||
|
|| checkpoint.skipped_object_count > 0
|
||||||
|
|| checkpoint.skipped_new_versions > 0
|
||||||
|
|| checkpoint.skipped_ilm_expired > 0
|
||||||
|
|| checkpoint.processed_bytes > 0
|
||||||
|
|| checkpoint.total_objects > 0
|
||||||
|
|| checkpoint.total_bytes > 0
|
||||||
|
|| checkpoint.baseline_generation.is_some()
|
||||||
|
|| checkpoint.counter_unknown;
|
||||||
|
let checkpoint_generation_mismatch = checkpoint.baseline_known && checkpoint.baseline_generation != baseline_generation;
|
||||||
|
let mut restored_counter_unknown = state.counter_unknown || checkpoint.counter_unknown;
|
||||||
|
if checkpoint_has_progress {
|
||||||
|
successful_objects = checkpoint.successful_objects;
|
||||||
|
failed_objects = checkpoint.failed_object_count;
|
||||||
|
skipped_objects = checkpoint.skipped_object_count;
|
||||||
|
let restored_processed_objects = successful_objects
|
||||||
|
.checked_add(failed_objects)
|
||||||
|
.and_then(|value| value.checked_add(skipped_objects))
|
||||||
|
.and_then(|value| value.checked_add(checkpoint.skipped_new_versions))
|
||||||
|
.and_then(|value| value.checked_add(checkpoint.skipped_ilm_expired));
|
||||||
|
let checkpoint_counter_overflow = restored_processed_objects.is_none();
|
||||||
|
restored_counter_unknown |= checkpoint_counter_overflow;
|
||||||
|
processed_objects = restored_processed_objects.unwrap_or(u64::MAX);
|
||||||
|
let mut progress = self.progress.write().await;
|
||||||
|
progress.objects_scanned = processed_objects;
|
||||||
|
progress.objects_healed = successful_objects;
|
||||||
|
progress.objects_failed = failed_objects;
|
||||||
|
progress.skipped_objects = skipped_objects;
|
||||||
|
progress.skipped_new_versions = checkpoint.skipped_new_versions;
|
||||||
|
progress.skipped_ilm_expired = checkpoint.skipped_ilm_expired;
|
||||||
|
if checkpoint.baseline_known && !checkpoint_generation_mismatch {
|
||||||
|
progress.objects_total_count = checkpoint.total_objects;
|
||||||
|
progress.objects_total_size = checkpoint.total_bytes;
|
||||||
|
progress.baseline_generation = checkpoint.baseline_generation;
|
||||||
|
progress.baseline_known = true;
|
||||||
|
}
|
||||||
|
progress.bytes_processed = checkpoint.processed_bytes;
|
||||||
|
progress.counter_unknown = state.counter_unknown || checkpoint.counter_unknown;
|
||||||
|
progress.refresh_progress_percentage();
|
||||||
|
if checkpoint_generation_mismatch || checkpoint_counter_overflow || progress.counter_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checkpoint_generation_mismatch {
|
||||||
|
restored_counter_unknown = true;
|
||||||
|
}
|
||||||
|
if restored_counter_unknown {
|
||||||
|
checkpoint_manager.mark_counter_unknown().await?;
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
let mut failed_buckets = 0u64;
|
let mut failed_buckets = 0u64;
|
||||||
|
|
||||||
// 4. process remaining buckets
|
// 4. process remaining buckets
|
||||||
for (bucket_idx, bucket) in buckets.iter().enumerate().skip(current_bucket_index) {
|
for (bucket_idx, bucket) in buckets.iter().enumerate().skip(current_bucket_index) {
|
||||||
// check if completed
|
// check if completed
|
||||||
if state.completed_buckets.contains(bucket) {
|
if state.completed_buckets.contains(bucket) {
|
||||||
|
checkpoint_manager.complete_bucket(bucket_idx.saturating_add(1)).await?;
|
||||||
|
current_object_index = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,13 +590,42 @@ impl ErasureSetHealer {
|
|||||||
return bucket_result;
|
return bucket_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update checkpoint position
|
|
||||||
checkpoint_manager.update_position(bucket_idx, current_object_index).await?;
|
|
||||||
|
|
||||||
// update progress
|
// update progress
|
||||||
resume_manager
|
let progress_snapshot = self.progress.read().await;
|
||||||
.update_progress(processed_objects, successful_objects, failed_objects, skipped_objects)
|
let bytes_processed = progress_snapshot.bytes_processed;
|
||||||
|
let skipped_new_versions = progress_snapshot.skipped_new_versions;
|
||||||
|
let skipped_ilm_expired = progress_snapshot.skipped_ilm_expired;
|
||||||
|
let counter_unknown = progress_snapshot.counter_unknown;
|
||||||
|
drop(progress_snapshot);
|
||||||
|
// The checkpoint is the recovery authority for object progress.
|
||||||
|
// Publish its counters and fence before the resume summary so a
|
||||||
|
// crash between the two stores cannot make recovery select newer
|
||||||
|
// summary bytes with an older checkpoint ledger.
|
||||||
|
if counter_unknown {
|
||||||
|
checkpoint_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
|
checkpoint_manager
|
||||||
|
.update_progress(successful_objects, failed_objects, skipped_objects, bytes_processed)
|
||||||
.await?;
|
.await?;
|
||||||
|
checkpoint_manager
|
||||||
|
.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired)
|
||||||
|
.await?;
|
||||||
|
checkpoint_manager.update_position(bucket_idx, current_object_index).await?;
|
||||||
|
resume_manager
|
||||||
|
.update_progress_with_bytes(
|
||||||
|
processed_objects,
|
||||||
|
successful_objects,
|
||||||
|
failed_objects,
|
||||||
|
skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
resume_manager
|
||||||
|
.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired)
|
||||||
|
.await?;
|
||||||
|
if counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
|
|
||||||
// check cancel status
|
// check cancel status
|
||||||
if self.cancel_token.is_cancelled() {
|
if self.cancel_token.is_cancelled() {
|
||||||
@@ -542,6 +645,7 @@ impl ErasureSetHealer {
|
|||||||
match bucket_result {
|
match bucket_result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
resume_manager.complete_bucket(bucket).await?;
|
resume_manager.complete_bucket(bucket).await?;
|
||||||
|
checkpoint_manager.complete_bucket(bucket_idx.saturating_add(1)).await?;
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
event = EVENT_HEAL_ERASURE_BUCKET_STATE,
|
event = EVENT_HEAL_ERASURE_BUCKET_STATE,
|
||||||
@@ -567,7 +671,9 @@ impl ErasureSetHealer {
|
|||||||
error = %e,
|
error = %e,
|
||||||
"Erasure set bucket heal failed"
|
"Erasure set bucket heal failed"
|
||||||
);
|
);
|
||||||
// continue to next bucket, do not interrupt the whole process
|
// A single durable cursor and ledger cannot safely preserve
|
||||||
|
// this bucket while processing a later one.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,20 +881,48 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
// Per-version dedup identity — the single canonical key.
|
// Per-version dedup identity — the single canonical key.
|
||||||
let key = compose_key(&item.name, item.version_id.as_deref());
|
let key = compose_key(&item.name, item.version_id.as_deref());
|
||||||
if checkpoint.processed_objects.contains(&key) || checkpoint.skipped_objects.contains(&key) {
|
if checkpoint.processed_objects.contains(&key)
|
||||||
|
|| checkpoint.failed_objects.contains(&key)
|
||||||
|
|| checkpoint.skipped_objects.contains(&key)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_skip_new_version(item.mod_time_unix_nanos, started_at_secs) {
|
if should_skip_new_version(item.mod_time_unix_nanos, started_at_secs) {
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
let counter_ok = increment_counter(processed_objects);
|
||||||
*processed_objects = processed_objects.saturating_add(1);
|
|
||||||
completed_in_page = completed_in_page.saturating_add(1);
|
completed_in_page = completed_in_page.saturating_add(1);
|
||||||
counter!("rustfs_heal_skipped_new_versions_total").increment(1);
|
counter!("rustfs_heal_skipped_new_versions_total").increment(1);
|
||||||
{
|
let (skipped_new, skipped_ilm, counter_unknown) = {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.record_skipped_new_version();
|
progress.record_skipped_new_version();
|
||||||
progress.set_current_object(Some(format!("skipped_new: {bucket}/{}", item.name)));
|
progress.set_current_object(Some(format!("skipped_new: {bucket}/{}", item.name)));
|
||||||
progress.update_progress(*processed_objects, *successful_objects, *failed_objects, bytes_processed);
|
progress.update_object_progress(
|
||||||
|
*processed_objects,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if !counter_ok {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(progress.skipped_new_versions, progress.skipped_ilm_expired, progress.counter_unknown)
|
||||||
|
};
|
||||||
|
checkpoint_manager
|
||||||
|
.record_object_outcome(
|
||||||
|
key,
|
||||||
|
CheckpointObjectOutcome::Processed,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
skipped_new,
|
||||||
|
skipped_ilm,
|
||||||
|
!counter_ok || counter_unknown,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !counter_ok || counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
@@ -820,15 +954,40 @@ impl ErasureSetHealer {
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
let counter_ok = increment_counter(processed_objects);
|
||||||
*processed_objects = processed_objects.saturating_add(1);
|
|
||||||
completed_in_page = completed_in_page.saturating_add(1);
|
completed_in_page = completed_in_page.saturating_add(1);
|
||||||
counter!("rustfs_heal_skipped_ilm_expired_total").increment(1);
|
counter!("rustfs_heal_skipped_ilm_expired_total").increment(1);
|
||||||
{
|
let (skipped_new, skipped_ilm, counter_unknown) = {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.record_skipped_ilm_expired();
|
progress.record_skipped_ilm_expired();
|
||||||
progress.set_current_object(Some(format!("skipped_ilm: {bucket}/{}", item.name)));
|
progress.set_current_object(Some(format!("skipped_ilm: {bucket}/{}", item.name)));
|
||||||
progress.update_progress(*processed_objects, *successful_objects, *failed_objects, bytes_processed);
|
progress.update_object_progress(
|
||||||
|
*processed_objects,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if !counter_ok {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(progress.skipped_new_versions, progress.skipped_ilm_expired, progress.counter_unknown)
|
||||||
|
};
|
||||||
|
checkpoint_manager
|
||||||
|
.record_object_outcome(
|
||||||
|
key,
|
||||||
|
CheckpointObjectOutcome::Processed,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
skipped_new,
|
||||||
|
skipped_ilm,
|
||||||
|
!counter_ok || counter_unknown,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !counter_ok || counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
}
|
}
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
@@ -954,11 +1113,11 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
while let Some((key, object, version_id, result)) = page_tasks.next().await {
|
while let Some((key, object, version_id, result)) = page_tasks.next().await {
|
||||||
let (object_size, result) = result;
|
let (object_size, result) = result;
|
||||||
match result {
|
let mut telemetry_unknown = false;
|
||||||
|
let checkpoint_outcome = match result {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
*successful_objects += 1;
|
telemetry_unknown |= !increment_counter(successful_objects);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
@@ -971,11 +1130,11 @@ impl ErasureSetHealer {
|
|||||||
state = "healed",
|
state = "healed",
|
||||||
"Erasure set object healed"
|
"Erasure set object healed"
|
||||||
);
|
);
|
||||||
|
CheckpointObjectOutcome::Processed
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
checkpoint_manager.add_processed_object(key).await?;
|
telemetry_unknown |= !increment_counter(successful_objects);
|
||||||
*successful_objects += 1;
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
@@ -988,12 +1147,12 @@ impl ErasureSetHealer {
|
|||||||
state = "missing_treated_as_ok",
|
state = "missing_treated_as_ok",
|
||||||
"Erasure set missing object treated as ok"
|
"Erasure set missing object treated as ok"
|
||||||
);
|
);
|
||||||
|
CheckpointObjectOutcome::Processed
|
||||||
}
|
}
|
||||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||||
Err(Error::TransientSkip { message }) => {
|
Err(Error::TransientSkip { message }) => {
|
||||||
*skipped_objects += 1;
|
telemetry_unknown |= !increment_counter(skipped_objects);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
checkpoint_manager.add_skipped_object(key).await?;
|
|
||||||
demote_to_debug_when!(!take_failure_log_sample(&mut transient_skip_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
demote_to_debug_when!(!take_failure_log_sample(&mut transient_skip_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
component = LOG_COMPONENT_HEAL,
|
component = LOG_COMPONENT_HEAL,
|
||||||
@@ -1006,11 +1165,11 @@ impl ErasureSetHealer {
|
|||||||
error = %message,
|
error = %message,
|
||||||
"Erasure set object heal skipped due to transient error"
|
"Erasure set object heal skipped due to transient error"
|
||||||
});
|
});
|
||||||
|
CheckpointObjectOutcome::Skipped
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
*failed_objects += 1;
|
telemetry_unknown |= !increment_counter(failed_objects);
|
||||||
bytes_processed = bytes_processed.saturating_add(object_size);
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
checkpoint_manager.add_failed_object(key).await?;
|
|
||||||
demote_to_debug_when!(!take_failure_log_sample(&mut failure_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
demote_to_debug_when!(!take_failure_log_sample(&mut failure_samples_logged), warn, target: "rustfs::heal::erasure_healer", {
|
||||||
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
component = LOG_COMPONENT_HEAL,
|
component = LOG_COMPONENT_HEAL,
|
||||||
@@ -1023,15 +1182,42 @@ impl ErasureSetHealer {
|
|||||||
error = %err,
|
error = %err,
|
||||||
"Erasure set object heal failed"
|
"Erasure set object heal failed"
|
||||||
});
|
});
|
||||||
|
CheckpointObjectOutcome::Failed
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
*processed_objects += 1;
|
telemetry_unknown |= !increment_counter(processed_objects);
|
||||||
completed_in_page += 1;
|
completed_in_page += 1;
|
||||||
{
|
let (progress_unknown, skipped_new_versions, skipped_ilm_expired) = {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||||
progress.update_progress(*processed_objects, *successful_objects, *failed_objects, bytes_processed);
|
progress.update_object_progress(
|
||||||
|
*processed_objects,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if telemetry_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(progress.counter_unknown, progress.skipped_new_versions, progress.skipped_ilm_expired)
|
||||||
|
};
|
||||||
|
checkpoint_manager
|
||||||
|
.record_object_outcome(
|
||||||
|
key,
|
||||||
|
checkpoint_outcome,
|
||||||
|
*successful_objects,
|
||||||
|
*failed_objects,
|
||||||
|
*skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
skipped_new_versions,
|
||||||
|
skipped_ilm_expired,
|
||||||
|
telemetry_unknown || progress_unknown,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if telemetry_unknown || progress_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if completed_in_page.is_multiple_of(100) {
|
if completed_in_page.is_multiple_of(100) {
|
||||||
@@ -1041,16 +1227,22 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
*current_object_index = global_obj_idx;
|
*current_object_index = global_obj_idx;
|
||||||
|
|
||||||
// Persist the authoritative cursor FIRST (points at the next page
|
// Persist the checkpoint ledger and page position before exposing
|
||||||
// boundary), then prune the per-version dedup sets. Both are
|
// the next resume cursor. A crash before cursor publication keeps
|
||||||
// idempotent under crash: heal_object re-heals safely.
|
// the page identities available for exact-once replay.
|
||||||
let next_cursor = if is_truncated { next_token.clone() } else { None };
|
checkpoint_manager.advance_page(bucket_index, *current_object_index).await?;
|
||||||
resume_manager.set_resume_cursor(next_cursor.clone()).await?;
|
|
||||||
checkpoint_manager.complete_page(bucket_index, *current_object_index).await?;
|
|
||||||
// Check if there are more pages
|
// Check if there are more pages
|
||||||
if !is_truncated {
|
if !is_truncated {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
continuation_token = next_heal_listing_token(bucket, "", next_token, is_truncated)?;
|
||||||
|
if continuation_token.is_none() {
|
||||||
|
// A truncated page without a continuation token is terminal.
|
||||||
|
// Retain its ledger until bucket completion is durable.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
resume_manager.set_resume_cursor(continuation_token.clone()).await?;
|
||||||
|
checkpoint_manager.prune_completed_page().await?;
|
||||||
|
|
||||||
// Anti-loop guard: an empty page reported as truncated cannot advance
|
// Anti-loop guard: an empty page reported as truncated cannot advance
|
||||||
// the cursor (there is no last identity to move past), so treat it as a
|
// the cursor (there is no last identity to move past), so treat it as a
|
||||||
@@ -1069,12 +1261,6 @@ impl ErasureSetHealer {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
previous_page_last = page_last;
|
previous_page_last = page_last;
|
||||||
|
|
||||||
continuation_token = next_heal_listing_token(bucket, "", next_token, is_truncated)?;
|
|
||||||
if continuation_token.is_none() {
|
|
||||||
// Truncated but no continuation token: treat as end of listing.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1083,10 +1269,66 @@ impl ErasureSetHealer {
|
|||||||
/// initialize progress tracking
|
/// initialize progress tracking
|
||||||
async fn initialize_progress(&self, _buckets: &[String], state: &crate::heal::resume::ResumeState) {
|
async fn initialize_progress(&self, _buckets: &[String], state: &crate::heal::resume::ResumeState) {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.objects_scanned = state.total_objects;
|
let existing_baseline = (
|
||||||
|
progress.objects_total_count,
|
||||||
|
progress.objects_total_size,
|
||||||
|
progress.baseline_generation,
|
||||||
|
progress.progress_state,
|
||||||
|
progress.baseline_known,
|
||||||
|
);
|
||||||
|
let baseline_generation_mismatch =
|
||||||
|
state.baseline_known && existing_baseline.4 && state.baseline_generation != existing_baseline.2;
|
||||||
|
let use_persisted_baseline = state.baseline_known && !baseline_generation_mismatch;
|
||||||
|
progress.objects_scanned = state.processed_objects;
|
||||||
progress.objects_healed = state.successful_objects;
|
progress.objects_healed = state.successful_objects;
|
||||||
progress.objects_failed = state.failed_objects;
|
progress.objects_failed = state.failed_objects;
|
||||||
progress.bytes_processed = 0; // Resume state tracks object counts, not byte counters.
|
progress.skipped_objects = state.skipped_objects;
|
||||||
|
progress.skipped_new_versions = state.skipped_new_versions;
|
||||||
|
progress.skipped_ilm_expired = state.skipped_ilm_expired;
|
||||||
|
progress.bytes_processed = state.processed_bytes;
|
||||||
|
progress.counter_unknown = state.counter_unknown;
|
||||||
|
if use_persisted_baseline
|
||||||
|
|| existing_baseline.0 > 0
|
||||||
|
|| existing_baseline.1 > 0
|
||||||
|
|| existing_baseline.2.is_some()
|
||||||
|
|| existing_baseline.4
|
||||||
|
{
|
||||||
|
progress.objects_total_count = if use_persisted_baseline {
|
||||||
|
state.total_objects
|
||||||
|
} else {
|
||||||
|
existing_baseline.0
|
||||||
|
};
|
||||||
|
progress.objects_total_size = if use_persisted_baseline {
|
||||||
|
state.total_bytes
|
||||||
|
} else {
|
||||||
|
existing_baseline.1
|
||||||
|
};
|
||||||
|
progress.baseline_generation = if use_persisted_baseline {
|
||||||
|
state.baseline_generation
|
||||||
|
} else {
|
||||||
|
existing_baseline.2
|
||||||
|
};
|
||||||
|
progress.baseline_known = use_persisted_baseline
|
||||||
|
|| existing_baseline.0 > 0
|
||||||
|
|| existing_baseline.1 > 0
|
||||||
|
|| existing_baseline.2.is_some()
|
||||||
|
|| existing_baseline.4;
|
||||||
|
}
|
||||||
|
progress.progress_state = if use_persisted_baseline
|
||||||
|
|| existing_baseline.0 > 0
|
||||||
|
|| existing_baseline.1 > 0
|
||||||
|
|| existing_baseline.2.is_some()
|
||||||
|
|| existing_baseline.4
|
||||||
|
{
|
||||||
|
crate::heal::progress::HealProgressState::Running
|
||||||
|
} else {
|
||||||
|
crate::heal::progress::HealProgressState::Indeterminate
|
||||||
|
};
|
||||||
|
if baseline_generation_mismatch || state.counter_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
progress.ledger_complete = false;
|
||||||
|
progress.refresh_progress_percentage();
|
||||||
progress.start_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.start_time));
|
progress.start_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.start_time));
|
||||||
progress.last_update_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.last_update));
|
progress.last_update_time = UNIX_EPOCH.checked_add(Duration::from_secs(state.last_update));
|
||||||
progress.set_current_object(state.current_object.clone());
|
progress.set_current_object(state.current_object.clone());
|
||||||
@@ -1264,8 +1506,8 @@ mod resume_loop_tests {
|
|||||||
};
|
};
|
||||||
use crate::heal::progress::HealProgress;
|
use crate::heal::progress::HealProgress;
|
||||||
use crate::heal::resume::{
|
use crate::heal::resume::{
|
||||||
CheckpointManager, RESUME_CHECKPOINT_FILE, ReplacementTargetIdentity, ResumeDeleteFailure, ResumeManager, ResumeUtils,
|
CheckpointManager, CheckpointObjectOutcome, RESUME_CHECKPOINT_FILE, ReplacementTargetIdentity, ResumeDeleteFailure,
|
||||||
compose_key,
|
ResumeManager, ResumeUtils, compose_key,
|
||||||
};
|
};
|
||||||
use crate::heal::storage::{HealLifecycleExpiryContext, HealListItem, HealObjectInfo, HealStorageAPI};
|
use crate::heal::storage::{HealLifecycleExpiryContext, HealListItem, HealObjectInfo, HealStorageAPI};
|
||||||
use crate::heal::storage_api::status::BucketInfo;
|
use crate::heal::storage_api::status::BucketInfo;
|
||||||
@@ -1405,6 +1647,7 @@ mod resume_loop_tests {
|
|||||||
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
|
list_include_lifecycle_object_info: Mutex<Vec<bool>>,
|
||||||
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
|
replacement_target_identity_sequences: Mutex<VecDeque<Vec<ReplacementTargetIdentity>>>,
|
||||||
fail_listing: AtomicBool,
|
fail_listing: AtomicBool,
|
||||||
|
fail_listing_buckets: Mutex<HashSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeStorage {
|
impl FakeStorage {
|
||||||
@@ -1441,6 +1684,9 @@ mod resume_loop_tests {
|
|||||||
fn fail_listing(&self) {
|
fn fail_listing(&self) {
|
||||||
self.fail_listing.store(true, Ordering::SeqCst);
|
self.fail_listing.store(true, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
fn fail_bucket_listing(&self, bucket: &str) {
|
||||||
|
self.fail_listing_buckets.lock().unwrap().insert(bucket.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -1531,7 +1777,7 @@ mod resume_loop_tests {
|
|||||||
}
|
}
|
||||||
async fn list_objects_for_heal_page(
|
async fn list_objects_for_heal_page(
|
||||||
&self,
|
&self,
|
||||||
_bucket: &str,
|
bucket: &str,
|
||||||
_prefix: &str,
|
_prefix: &str,
|
||||||
continuation_token: Option<&str>,
|
continuation_token: Option<&str>,
|
||||||
include_lifecycle_object_info: bool,
|
include_lifecycle_object_info: bool,
|
||||||
@@ -1540,7 +1786,7 @@ mod resume_loop_tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.push(include_lifecycle_object_info);
|
.push(include_lifecycle_object_info);
|
||||||
if self.fail_listing.load(Ordering::SeqCst) {
|
if self.fail_listing.load(Ordering::SeqCst) || self.fail_listing_buckets.lock().unwrap().contains(bucket) {
|
||||||
return Err(Error::other("injected listing failure"));
|
return Err(Error::other("injected listing failure"));
|
||||||
}
|
}
|
||||||
let key = continuation_token.map(str::to_string);
|
let key = continuation_token.map(str::to_string);
|
||||||
@@ -1918,6 +2164,49 @@ mod resume_loop_tests {
|
|||||||
assert!(state.completed_buckets.is_empty(), "the failed bucket must remain resumable");
|
assert!(state.completed_buckets.is_empty(), "the failed bucket must remain resumable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_failure_stops_before_a_later_bucket_checkpoint() {
|
||||||
|
let env = make_env().await;
|
||||||
|
let task_id = ResumeUtils::generate_task_id();
|
||||||
|
let buckets = vec!["a".to_string(), "b".to_string()];
|
||||||
|
let resume = ResumeManager::new(
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
task_id.clone(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
buckets.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::new(env.healer.disk.clone(), task_id.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.storage.fail_bucket_listing("a");
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert!(resume.schedule_retry().await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&buckets, "pool_0_set_0", &resume, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("the first bucket failure must keep the pass incomplete");
|
||||||
|
let persisted = checkpoint.get_checkpoint().await;
|
||||||
|
assert_eq!(persisted.current_bucket_index, 0);
|
||||||
|
assert!(resume.get_state().await.completed_buckets.is_empty());
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&buckets, "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("recovery must retry the earlier failed bucket");
|
||||||
|
assert!(!resumed.get_state().await.completed);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn completed_resume_state_is_not_selected_for_a_new_heal() {
|
async fn completed_resume_state_is_not_selected_for_a_new_heal() {
|
||||||
let env = make_env().await;
|
let env = make_env().await;
|
||||||
@@ -2107,8 +2396,175 @@ mod resume_loop_tests {
|
|||||||
let mut names: Vec<String> = env.storage.calls().into_iter().map(|(n, _)| n).collect();
|
let mut names: Vec<String> = env.storage.calls().into_iter().map(|(n, _)| n).collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
assert_eq!(names, vec!["a", "b", "c", "d"], "every object exactly once, none dropped/doubled");
|
assert_eq!(names, vec!["a", "b", "c", "d"], "every object exactly once, none dropped/doubled");
|
||||||
// Final page not truncated => cursor cleared.
|
// Keep the final page cursor until the outer loop durably completes the
|
||||||
assert_eq!(env.resume.resume_cursor().await, None);
|
// bucket, so a crash can replay only this page against its identities.
|
||||||
|
assert_eq!(env.resume.resume_cursor().await, Some("t1".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persisted_failure_waits_for_the_bounded_retry_after_page_replay() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("object", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
env.checkpoint
|
||||||
|
.record_object_outcome(
|
||||||
|
compose_key("object", Some("v1")),
|
||||||
|
CheckpointObjectOutcome::Failed,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.checkpoint.advance_page(0, 1).await.unwrap();
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("the persisted failure must schedule a bounded retry");
|
||||||
|
assert!(
|
||||||
|
env.storage.calls().is_empty(),
|
||||||
|
"the failed identity must not be repeated in the same pass"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("the bounded retry must heal the object");
|
||||||
|
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
|
||||||
|
let state = resumed.get_state().await;
|
||||||
|
assert_eq!(state.successful_objects, 1);
|
||||||
|
assert_eq!(state.failed_objects, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn final_page_crash_replays_only_the_retained_page_identities() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("first", Some("v1"), false)],
|
||||||
|
next: Some("final-page".to_string()),
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
env.storage.set_page(
|
||||||
|
Some("final-page"),
|
||||||
|
Page {
|
||||||
|
items: vec![item("last", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||||
|
result.expect("the bucket pass must finish before the simulated crash");
|
||||||
|
assert_eq!((processed, successful, failed, skipped), (2, 2, 0, 0));
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("the retained final-page ledger must make recovery exact");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.storage.calls(),
|
||||||
|
vec![
|
||||||
|
("first".to_string(), Some("v1".to_string())),
|
||||||
|
("last".to_string(), Some("v1".to_string()))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let state = resumed.get_state().await;
|
||||||
|
assert_eq!(state.successful_objects, 2);
|
||||||
|
assert_eq!(state.processed_objects, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn truncated_page_without_token_retains_its_replay_ledger() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("object", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (processed, successful, failed, skipped, result) = run(&env).await;
|
||||||
|
result.expect("the tokenless truncated page is a terminal page");
|
||||||
|
assert_eq!((processed, successful, failed, skipped), (1, 1, 0, 0));
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("terminal-page recovery must not replay a durable identity");
|
||||||
|
|
||||||
|
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
|
||||||
|
let state = resumed.get_state().await;
|
||||||
|
assert_eq!(state.successful_objects, 1);
|
||||||
|
assert_eq!(state.processed_objects, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completed_bucket_reconciles_its_final_page_checkpoint_after_crash() {
|
||||||
|
let env = make_env().await;
|
||||||
|
env.storage.set_page(
|
||||||
|
None,
|
||||||
|
Page {
|
||||||
|
items: vec![item("object", Some("v1"), false)],
|
||||||
|
next: None,
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, _, _, _, result) = run(&env).await;
|
||||||
|
result.expect("the bucket pass must finish before the simulated crash");
|
||||||
|
env.resume.complete_bucket("b").await.unwrap();
|
||||||
|
|
||||||
|
let resumed = ResumeManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let checkpoint = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
env.healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &resumed, &checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("recovery must finish the checkpoint transition without replaying the bucket");
|
||||||
|
|
||||||
|
assert_eq!(env.storage.calls(), vec![("object".to_string(), Some("v1".to_string()))]);
|
||||||
|
let checkpoint = checkpoint.get_checkpoint().await;
|
||||||
|
assert_eq!(checkpoint.current_bucket_index, 1);
|
||||||
|
assert!(checkpoint.processed_objects.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2012,16 +2012,44 @@ impl HealManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut snapshot = HealProgress::default();
|
let mut snapshot = HealProgress::default();
|
||||||
|
let mut has_object_sweep = false;
|
||||||
|
let mut all_object_baselines_known = true;
|
||||||
|
let mut counter_overflow = false;
|
||||||
|
let mut stage_current = 0_u64;
|
||||||
|
let mut stage_total = 0_u64;
|
||||||
for task in active_tasks {
|
for task in active_tasks {
|
||||||
let progress = task.get_progress().await;
|
let progress = task.get_progress().await;
|
||||||
snapshot.objects_scanned = snapshot.objects_scanned.saturating_add(progress.objects_scanned);
|
let object_sweep = matches!(progress.kind, crate::heal::progress::HealProgressKind::ObjectSweep);
|
||||||
snapshot.objects_healed = snapshot.objects_healed.saturating_add(progress.objects_healed);
|
has_object_sweep |= object_sweep;
|
||||||
snapshot.objects_failed = snapshot.objects_failed.saturating_add(progress.objects_failed);
|
if object_sweep {
|
||||||
snapshot.skipped_new_versions = snapshot.skipped_new_versions.saturating_add(progress.skipped_new_versions);
|
all_object_baselines_known &= progress.baseline_known;
|
||||||
snapshot.skipped_ilm_expired = snapshot.skipped_ilm_expired.saturating_add(progress.skipped_ilm_expired);
|
}
|
||||||
snapshot.objects_total_count = snapshot.objects_total_count.saturating_add(progress.objects_total_count);
|
counter_overflow |=
|
||||||
snapshot.objects_total_size = snapshot.objects_total_size.saturating_add(progress.objects_total_size);
|
progress.counter_unknown || matches!(progress.progress_state, crate::heal::progress::HealProgressState::Unknown);
|
||||||
snapshot.bytes_processed = snapshot.bytes_processed.saturating_add(progress.bytes_processed);
|
match stage_current.checked_add(progress.stage_current) {
|
||||||
|
Some(sum) => stage_current = sum,
|
||||||
|
None => counter_overflow = true,
|
||||||
|
}
|
||||||
|
match stage_total.checked_add(progress.stage_total) {
|
||||||
|
Some(sum) => stage_total = sum,
|
||||||
|
None => counter_overflow = true,
|
||||||
|
}
|
||||||
|
for (target, value) in [
|
||||||
|
(&mut snapshot.objects_scanned, progress.objects_scanned),
|
||||||
|
(&mut snapshot.objects_healed, progress.objects_healed),
|
||||||
|
(&mut snapshot.objects_failed, progress.objects_failed),
|
||||||
|
(&mut snapshot.skipped_objects, progress.skipped_objects),
|
||||||
|
(&mut snapshot.skipped_new_versions, progress.skipped_new_versions),
|
||||||
|
(&mut snapshot.skipped_ilm_expired, progress.skipped_ilm_expired),
|
||||||
|
(&mut snapshot.objects_total_count, progress.objects_total_count),
|
||||||
|
(&mut snapshot.objects_total_size, progress.objects_total_size),
|
||||||
|
(&mut snapshot.bytes_processed, progress.bytes_processed),
|
||||||
|
] {
|
||||||
|
match target.checked_add(value) {
|
||||||
|
Some(sum) => *target = sum,
|
||||||
|
None => counter_overflow = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
snapshot.start_time = match (snapshot.start_time, progress.start_time) {
|
snapshot.start_time = match (snapshot.start_time, progress.start_time) {
|
||||||
(Some(current), Some(next)) => Some(current.min(next)),
|
(Some(current), Some(next)) => Some(current.min(next)),
|
||||||
(None, next) => next,
|
(None, next) => next,
|
||||||
@@ -2036,7 +2064,36 @@ impl HealManager {
|
|||||||
snapshot.current_object = progress.current_object;
|
snapshot.current_object = progress.current_object;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot.refresh_progress_percentage();
|
snapshot.kind = if has_object_sweep {
|
||||||
|
crate::heal::progress::HealProgressKind::ObjectSweep
|
||||||
|
} else {
|
||||||
|
crate::heal::progress::HealProgressKind::Stage
|
||||||
|
};
|
||||||
|
snapshot.stage_current = stage_current;
|
||||||
|
snapshot.stage_total = stage_total;
|
||||||
|
snapshot.baseline_known = has_object_sweep && all_object_baselines_known;
|
||||||
|
snapshot.progress_state = if counter_overflow {
|
||||||
|
crate::heal::progress::HealProgressState::Unknown
|
||||||
|
} else if has_object_sweep && !all_object_baselines_known {
|
||||||
|
crate::heal::progress::HealProgressState::Indeterminate
|
||||||
|
} else if has_object_sweep {
|
||||||
|
crate::heal::progress::HealProgressState::Running
|
||||||
|
} else if stage_total == 0 {
|
||||||
|
crate::heal::progress::HealProgressState::Indeterminate
|
||||||
|
} else {
|
||||||
|
crate::heal::progress::HealProgressState::Running
|
||||||
|
};
|
||||||
|
if counter_overflow {
|
||||||
|
snapshot.progress_percentage = 0.0;
|
||||||
|
} else if !has_object_sweep {
|
||||||
|
snapshot.progress_percentage = if stage_total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
((stage_current as f64 / stage_total as f64) * 100.0).min(99.999)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
snapshot.refresh_progress_percentage();
|
||||||
|
}
|
||||||
snapshot.refresh_estimated_completion_time();
|
snapshot.refresh_estimated_completion_time();
|
||||||
Some(snapshot)
|
Some(snapshot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,70 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
pub(crate) fn increment_counter(counter: &mut u64) -> bool {
|
||||||
|
match counter.checked_add(1) {
|
||||||
|
Some(next) => {
|
||||||
|
*counter = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*counter = u64::MAX;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_bytes(total: &mut u64, amount: u64) -> bool {
|
||||||
|
match total.checked_add(amount) {
|
||||||
|
Some(next) => {
|
||||||
|
*total = next;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
*total = u64::MAX;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum HealProgressKind {
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
Stage,
|
||||||
|
ObjectSweep,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the object ledger can produce a meaningful percentage.
|
||||||
|
///
|
||||||
|
/// A zero-valued baseline is not a completed scan: it means that no complete
|
||||||
|
/// usage snapshot was available. Keep this state explicit so callers do not
|
||||||
|
/// mistake the legacy `0.0` wire value for a measured zero-percent result.
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum HealProgressState {
|
||||||
|
#[default]
|
||||||
|
Unknown,
|
||||||
|
Indeterminate,
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct HealProgress {
|
pub struct HealProgress {
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: HealProgressKind,
|
||||||
/// Objects scanned
|
/// Objects scanned
|
||||||
pub objects_scanned: u64,
|
pub objects_scanned: u64,
|
||||||
/// Objects healed
|
/// Objects healed
|
||||||
pub objects_healed: u64,
|
pub objects_healed: u64,
|
||||||
/// Objects failed
|
/// Objects failed
|
||||||
pub objects_failed: u64,
|
pub objects_failed: u64,
|
||||||
|
/// Versions deferred for a later retry pass.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_objects: u64,
|
||||||
/// Versions skipped because they were written after this heal started
|
/// Versions skipped because they were written after this heal started
|
||||||
pub skipped_new_versions: u64,
|
pub skipped_new_versions: u64,
|
||||||
/// Versions skipped because lifecycle already selected them for expiry
|
/// Versions skipped because lifecycle already selected them for expiry
|
||||||
@@ -44,11 +99,38 @@ pub struct HealProgress {
|
|||||||
pub last_update_time: Option<SystemTime>,
|
pub last_update_time: Option<SystemTime>,
|
||||||
/// Estimated completion time
|
/// Estimated completion time
|
||||||
pub estimated_completion_time: Option<SystemTime>,
|
pub estimated_completion_time: Option<SystemTime>,
|
||||||
|
/// Current stage number. Stage updates are intentionally independent from
|
||||||
|
/// the object ledger below.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stage_current: u64,
|
||||||
|
/// Number of stages in the current task.
|
||||||
|
#[serde(default)]
|
||||||
|
pub stage_total: u64,
|
||||||
|
/// Explicitly distinguishes a missing usage baseline from measured 0%.
|
||||||
|
#[serde(default)]
|
||||||
|
pub progress_state: HealProgressState,
|
||||||
|
/// True only after the task's durable completion ledger was committed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ledger_complete: bool,
|
||||||
|
/// Generation of the usage snapshot used for the baseline, if available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_generation: Option<u64>,
|
||||||
|
/// Whether the baseline was explicitly observed. This is separate from
|
||||||
|
/// the counters so a known empty scope (0 objects, 0 bytes) is not
|
||||||
|
/// confused with a legacy snapshot that omitted the baseline fields.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_known: bool,
|
||||||
|
/// Internal telemetry fence set when an aggregate counter overflows or
|
||||||
|
/// becomes inconsistent. It prevents a later refresh from fabricating a
|
||||||
|
/// percentage from the poisoned values.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_unknown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HealProgress {
|
impl HealProgress {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
kind: HealProgressKind::Unknown,
|
||||||
start_time: Some(SystemTime::now()),
|
start_time: Some(SystemTime::now()),
|
||||||
last_update_time: Some(SystemTime::now()),
|
last_update_time: Some(SystemTime::now()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -56,12 +138,87 @@ impl HealProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_progress(&mut self, scanned: u64, healed: u64, failed: u64, bytes: u64) {
|
pub fn update_progress(&mut self, scanned: u64, healed: u64, failed: u64, bytes: u64) {
|
||||||
|
self.update_object_sweep_progress(scanned, healed, failed, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_object_sweep_progress(&mut self, scanned: u64, healed: u64, failed: u64, bytes: u64) {
|
||||||
|
self.kind = HealProgressKind::ObjectSweep;
|
||||||
self.objects_scanned = scanned;
|
self.objects_scanned = scanned;
|
||||||
self.objects_healed = healed;
|
self.objects_healed = healed;
|
||||||
self.objects_failed = failed;
|
self.objects_failed = failed;
|
||||||
self.bytes_processed = bytes;
|
self.bytes_processed = bytes;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
|
||||||
|
let explicit_skipped = match self.skipped_new_versions.checked_add(self.skipped_ilm_expired) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
self.mark_unknown();
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let skipped = healed
|
||||||
|
.checked_add(failed)
|
||||||
|
.and_then(|value| value.checked_add(explicit_skipped))
|
||||||
|
.and_then(|value| scanned.checked_sub(value))
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.update_object_progress(scanned, healed, failed, skipped, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update task stage progress without modifying object counters.
|
||||||
|
pub fn update_stage(&mut self, current: u64, total: u64) {
|
||||||
|
let object_sweep_active = matches!(self.kind, HealProgressKind::ObjectSweep);
|
||||||
|
if !object_sweep_active {
|
||||||
|
self.kind = HealProgressKind::Stage;
|
||||||
|
}
|
||||||
|
self.ledger_complete = false;
|
||||||
|
self.stage_current = current.min(total);
|
||||||
|
self.stage_total = total;
|
||||||
|
if object_sweep_active {
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
self.refresh_progress_percentage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.progress_state = if total == 0 {
|
||||||
|
HealProgressState::Indeterminate
|
||||||
|
} else {
|
||||||
|
HealProgressState::Running
|
||||||
|
};
|
||||||
|
self.progress_percentage = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(current as f64 / total as f64 * 100.0).min(100.0)
|
||||||
|
};
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the disjoint object ledger. `scanned` is the number of terminal
|
||||||
|
/// object outcomes and must equal healed + failed + deferred skipped plus
|
||||||
|
/// the two terminal skip classes. Overflow is a corrupt/unknown counter
|
||||||
|
/// state, not a reason to abort a completed heal.
|
||||||
|
pub fn update_object_progress(&mut self, scanned: u64, healed: u64, failed: u64, skipped: u64, bytes: u64) {
|
||||||
|
self.kind = HealProgressKind::ObjectSweep;
|
||||||
|
// `skipped` is the transient/deferred class. The two explicit skip
|
||||||
|
// counters are terminal classifications too, so include them in the
|
||||||
|
// same ledger without making callers maintain a second aggregate.
|
||||||
|
let outcomes = healed
|
||||||
|
.checked_add(failed)
|
||||||
|
.and_then(|value| value.checked_add(skipped))
|
||||||
|
.and_then(|value| value.checked_add(self.skipped_new_versions))
|
||||||
|
.and_then(|value| value.checked_add(self.skipped_ilm_expired));
|
||||||
|
self.objects_scanned = scanned;
|
||||||
|
self.objects_healed = healed;
|
||||||
|
self.objects_failed = failed;
|
||||||
|
self.skipped_objects = skipped;
|
||||||
|
self.bytes_processed = bytes;
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
self.ledger_complete = false;
|
||||||
|
if outcomes != Some(scanned) {
|
||||||
|
// Telemetry corruption must not abort a heal. Preserve the
|
||||||
|
// counters for diagnostics, but do not derive a percentage from a
|
||||||
|
// double-counted or overflowing ledger.
|
||||||
|
self.mark_unknown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
@@ -69,50 +226,88 @@ impl HealProgress {
|
|||||||
pub fn set_total_baseline(&mut self, objects_total_count: u64, objects_total_size: u64) {
|
pub fn set_total_baseline(&mut self, objects_total_count: u64, objects_total_size: u64) {
|
||||||
self.objects_total_count = objects_total_count;
|
self.objects_total_count = objects_total_count;
|
||||||
self.objects_total_size = objects_total_size;
|
self.objects_total_size = objects_total_size;
|
||||||
|
self.baseline_known = true;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_total_baseline_with_generation(&mut self, objects_total_count: u64, objects_total_size: u64, generation: u64) {
|
||||||
|
self.baseline_generation = Some(generation);
|
||||||
|
self.set_total_baseline(objects_total_count, objects_total_size);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_skipped_new_version(&mut self) {
|
pub fn record_skipped_new_version(&mut self) {
|
||||||
self.skipped_new_versions = self.skipped_new_versions.saturating_add(1);
|
let Some(next) = self.skipped_new_versions.checked_add(1) else {
|
||||||
|
self.mark_unknown();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.skipped_new_versions = next;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_skipped_ilm_expired(&mut self) {
|
pub fn record_skipped_ilm_expired(&mut self) {
|
||||||
self.skipped_ilm_expired = self.skipped_ilm_expired.saturating_add(1);
|
let Some(next) = self.skipped_ilm_expired.checked_add(1) else {
|
||||||
|
self.mark_unknown();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.skipped_ilm_expired = next;
|
||||||
self.last_update_time = Some(SystemTime::now());
|
self.last_update_time = Some(SystemTime::now());
|
||||||
self.refresh_progress_percentage();
|
self.refresh_progress_percentage();
|
||||||
self.refresh_estimated_completion_time();
|
self.refresh_estimated_completion_time();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn completed_for_baseline(&self) -> u64 {
|
fn completed_for_baseline(&self) -> Option<u64> {
|
||||||
self.objects_healed
|
self.objects_healed
|
||||||
.saturating_add(self.objects_failed)
|
.checked_add(self.objects_failed)?
|
||||||
.saturating_add(self.skipped_new_versions)
|
.checked_add(self.skipped_objects)?
|
||||||
.saturating_add(self.skipped_ilm_expired)
|
.checked_add(self.skipped_new_versions)?
|
||||||
|
.checked_add(self.skipped_ilm_expired)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn refresh_progress_percentage(&mut self) {
|
pub(crate) fn refresh_progress_percentage(&mut self) {
|
||||||
|
if self.ledger_complete {
|
||||||
|
self.progress_state = HealProgressState::Completed;
|
||||||
|
self.progress_percentage = 100.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.counter_unknown {
|
||||||
|
self.progress_state = HealProgressState::Unknown;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.baseline_known {
|
||||||
|
self.progress_state = HealProgressState::Indeterminate;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
self.estimated_completion_time = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.objects_total_size > 0 {
|
if self.objects_total_size > 0 {
|
||||||
self.progress_percentage = ((self.bytes_processed as f64 / self.objects_total_size as f64) * 100.0).min(100.0);
|
self.progress_percentage = ((self.bytes_processed as f64 / self.objects_total_size as f64) * 100.0).min(100.0);
|
||||||
|
self.progress_percentage = self.progress_percentage.min(99.999);
|
||||||
|
self.progress_state = HealProgressState::Running;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.objects_total_count > 0 {
|
if self.objects_total_count > 0 {
|
||||||
let completed = self.completed_for_baseline();
|
let Some(completed) = self.completed_for_baseline() else {
|
||||||
|
self.progress_state = HealProgressState::Unknown;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
return;
|
||||||
|
};
|
||||||
self.progress_percentage = ((completed as f64 / self.objects_total_count as f64) * 100.0).min(100.0);
|
self.progress_percentage = ((completed as f64 / self.objects_total_count as f64) * 100.0).min(100.0);
|
||||||
|
self.progress_percentage = self.progress_percentage.min(99.999);
|
||||||
|
self.progress_state = HealProgressState::Running;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.baseline_known {
|
||||||
let total = self
|
self.progress_state = HealProgressState::Running;
|
||||||
.objects_scanned
|
self.progress_percentage = 0.0;
|
||||||
.saturating_add(self.objects_healed)
|
return;
|
||||||
.saturating_add(self.objects_failed);
|
|
||||||
if total > 0 {
|
|
||||||
self.progress_percentage = (self.objects_healed as f64 / total as f64) * 100.0;
|
|
||||||
}
|
}
|
||||||
|
self.progress_state = HealProgressState::Indeterminate;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_current_object(&mut self, object: Option<String>) {
|
pub fn set_current_object(&mut self, object: Option<String>) {
|
||||||
@@ -125,7 +320,11 @@ impl HealProgress {
|
|||||||
self.estimated_completion_time = None;
|
self.estimated_completion_time = None;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.is_completed() || !(0.0..100.0).contains(&self.progress_percentage) || self.bytes_processed == 0 {
|
if self.is_completed()
|
||||||
|
|| self.progress_percentage <= 0.0
|
||||||
|
|| self.progress_percentage >= 100.0
|
||||||
|
|| self.bytes_processed == 0
|
||||||
|
{
|
||||||
self.estimated_completion_time = None;
|
self.estimated_completion_time = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,18 +341,39 @@ impl HealProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_completed(&self) -> bool {
|
pub fn is_completed(&self) -> bool {
|
||||||
if self.progress_percentage >= 100.0 {
|
self.ledger_complete
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
if self.objects_total_count > 0 || self.objects_total_size > 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.objects_scanned > 0 && self.objects_healed.saturating_add(self.objects_failed) >= self.objects_scanned
|
/// Mark telemetry unknown while allowing the underlying heal operation to
|
||||||
|
/// continue. This is used for corrupt/overflowing counters at the
|
||||||
|
/// observability boundary; it must never turn a successful heal into an
|
||||||
|
/// execution error.
|
||||||
|
pub fn mark_unknown(&mut self) {
|
||||||
|
self.counter_unknown = true;
|
||||||
|
self.progress_state = HealProgressState::Unknown;
|
||||||
|
self.ledger_complete = false;
|
||||||
|
self.progress_percentage = 0.0;
|
||||||
|
self.estimated_completion_time = None;
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the object ledger terminal only after the enclosing task has
|
||||||
|
/// committed all durable resume state and cleanup fences.
|
||||||
|
pub fn mark_completed(&mut self) {
|
||||||
|
let telemetry_unknown = self.counter_unknown || self.progress_state == HealProgressState::Unknown;
|
||||||
|
self.ledger_complete = true;
|
||||||
|
if !telemetry_unknown {
|
||||||
|
self.progress_state = HealProgressState::Completed;
|
||||||
|
}
|
||||||
|
self.progress_percentage = 100.0;
|
||||||
|
self.last_update_time = Some(SystemTime::now());
|
||||||
|
self.estimated_completion_time = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_success_rate(&self) -> f64 {
|
pub fn get_success_rate(&self) -> f64 {
|
||||||
let total = self.objects_healed + self.objects_failed;
|
let Some(total) = self.objects_healed.checked_add(self.objects_failed) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
(self.objects_healed as f64 / total as f64) * 100.0
|
(self.objects_healed as f64 / total as f64) * 100.0
|
||||||
} else {
|
} else {
|
||||||
@@ -230,6 +450,7 @@ mod tests {
|
|||||||
assert_eq!(progress.objects_scanned, 0);
|
assert_eq!(progress.objects_scanned, 0);
|
||||||
assert_eq!(progress.objects_healed, 0);
|
assert_eq!(progress.objects_healed, 0);
|
||||||
assert_eq!(progress.objects_failed, 0);
|
assert_eq!(progress.objects_failed, 0);
|
||||||
|
assert_eq!(progress.skipped_objects, 0);
|
||||||
assert_eq!(progress.skipped_new_versions, 0);
|
assert_eq!(progress.skipped_new_versions, 0);
|
||||||
assert_eq!(progress.skipped_ilm_expired, 0);
|
assert_eq!(progress.skipped_ilm_expired, 0);
|
||||||
assert_eq!(progress.objects_total_count, 0);
|
assert_eq!(progress.objects_total_count, 0);
|
||||||
@@ -250,10 +471,8 @@ mod tests {
|
|||||||
assert_eq!(progress.objects_healed, 8);
|
assert_eq!(progress.objects_healed, 8);
|
||||||
assert_eq!(progress.objects_failed, 2);
|
assert_eq!(progress.objects_failed, 2);
|
||||||
assert_eq!(progress.bytes_processed, 1024);
|
assert_eq!(progress.bytes_processed, 1024);
|
||||||
// Progress percentage should be calculated based on healed/total
|
assert_eq!(progress.progress_state, HealProgressState::Indeterminate);
|
||||||
// total = scanned + healed + failed = 10 + 8 + 2 = 20
|
assert_eq!(progress.progress_percentage, 0.0);
|
||||||
// healed/total = 8/20 = 0.4 = 40%
|
|
||||||
assert!((progress.progress_percentage - 40.0).abs() < 0.001);
|
|
||||||
assert!(progress.last_update_time.is_some());
|
assert!(progress.last_update_time.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +481,8 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.start_time = Some(SystemTime::now() - Duration::from_secs(10));
|
progress.start_time = Some(SystemTime::now() - Duration::from_secs(10));
|
||||||
|
|
||||||
progress.update_progress(100, 25, 0, 4096);
|
progress.set_total_baseline(100, 16384);
|
||||||
|
progress.update_progress(25, 25, 0, 4096);
|
||||||
|
|
||||||
let eta = progress
|
let eta = progress
|
||||||
.estimated_completion_time
|
.estimated_completion_time
|
||||||
@@ -275,7 +495,7 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.set_total_baseline(10, 8192);
|
progress.set_total_baseline(10, 8192);
|
||||||
|
|
||||||
progress.update_progress(100, 25, 0, 4096);
|
progress.update_progress(25, 25, 0, 4096);
|
||||||
|
|
||||||
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
@@ -285,7 +505,7 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.set_total_baseline(10, 0);
|
progress.set_total_baseline(10, 0);
|
||||||
|
|
||||||
progress.update_progress(100, 3, 2, 0);
|
progress.update_progress(5, 3, 2, 0);
|
||||||
|
|
||||||
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 50.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
@@ -295,7 +515,7 @@ mod tests {
|
|||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.set_total_baseline(10, 0);
|
progress.set_total_baseline(10, 0);
|
||||||
|
|
||||||
progress.update_progress(100, 3, 2, 0);
|
progress.update_progress(5, 3, 2, 0);
|
||||||
progress.record_skipped_new_version();
|
progress.record_skipped_new_version();
|
||||||
|
|
||||||
assert_eq!(progress.skipped_new_versions, 1);
|
assert_eq!(progress.skipped_new_versions, 1);
|
||||||
@@ -336,7 +556,8 @@ mod tests {
|
|||||||
fn test_heal_progress_update_progress_all_healed() {
|
fn test_heal_progress_update_progress_all_healed() {
|
||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
// When scanned=0, healed=10, failed=0: total=10, progress = 10/10 = 100%
|
// When scanned=0, healed=10, failed=0: total=10, progress = 10/10 = 100%
|
||||||
progress.update_progress(0, 10, 0, 2048);
|
progress.update_progress(10, 10, 0, 2048);
|
||||||
|
progress.mark_completed();
|
||||||
|
|
||||||
// All healed, should be 100%
|
// All healed, should be 100%
|
||||||
assert!((progress.progress_percentage - 100.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 100.0).abs() < 0.001);
|
||||||
@@ -394,6 +615,7 @@ mod tests {
|
|||||||
assert_eq!(json["objectsScanned"], 10);
|
assert_eq!(json["objectsScanned"], 10);
|
||||||
assert_eq!(json["objectsHealed"], 8);
|
assert_eq!(json["objectsHealed"], 8);
|
||||||
assert_eq!(json["objectsFailed"], 2);
|
assert_eq!(json["objectsFailed"], 2);
|
||||||
|
assert_eq!(json["skippedObjects"], 0);
|
||||||
assert_eq!(json["skippedNewVersions"], 0);
|
assert_eq!(json["skippedNewVersions"], 0);
|
||||||
assert_eq!(json["skippedIlmExpired"], 0);
|
assert_eq!(json["skippedIlmExpired"], 0);
|
||||||
assert_eq!(json["bytesProcessed"], 1024);
|
assert_eq!(json["bytesProcessed"], 1024);
|
||||||
@@ -405,6 +627,7 @@ mod tests {
|
|||||||
fn test_heal_progress_is_completed_by_percentage() {
|
fn test_heal_progress_is_completed_by_percentage() {
|
||||||
let mut progress = HealProgress::new();
|
let mut progress = HealProgress::new();
|
||||||
progress.update_progress(10, 10, 0, 1024);
|
progress.update_progress(10, 10, 0, 1024);
|
||||||
|
progress.mark_completed();
|
||||||
|
|
||||||
assert!(progress.is_completed());
|
assert!(progress.is_completed());
|
||||||
}
|
}
|
||||||
@@ -415,7 +638,7 @@ mod tests {
|
|||||||
progress.objects_scanned = 10;
|
progress.objects_scanned = 10;
|
||||||
progress.objects_healed = 8;
|
progress.objects_healed = 8;
|
||||||
progress.objects_failed = 2;
|
progress.objects_failed = 2;
|
||||||
// healed + failed = 8 + 2 = 10 >= scanned = 10
|
progress.mark_completed();
|
||||||
assert!(progress.is_completed());
|
assert!(progress.is_completed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +678,66 @@ mod tests {
|
|||||||
assert!((progress.get_success_rate() - 100.0).abs() < 0.001);
|
assert!((progress.get_success_rate() - 100.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_object_progress_reaches_terminal_100() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
assert!(!progress.is_completed());
|
||||||
|
progress.mark_completed();
|
||||||
|
assert!(progress.is_completed());
|
||||||
|
assert_eq!(progress.progress_percentage, 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_without_baseline_is_indeterminate() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
assert_eq!(progress.progress_state, HealProgressState::Indeterminate);
|
||||||
|
assert_eq!(progress.progress_percentage, 0.0);
|
||||||
|
assert!(progress.estimated_completion_time.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_retry_is_exactly_once() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.set_total_baseline(1, 128);
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
progress.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
assert_eq!(progress.objects_scanned, 1);
|
||||||
|
assert_eq!(progress.objects_healed, 1);
|
||||||
|
assert_eq!(progress.bytes_processed, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_never_triggers_cleanup_before_terminal_ledger_empty() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.progress_percentage = 100.0;
|
||||||
|
assert!(!progress.is_completed());
|
||||||
|
progress.mark_completed();
|
||||||
|
assert!(progress.is_completed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_counter_overflow_is_marked_unknown_without_aborting_completed_heal() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(u64::MAX, u64::MAX, 1, 0, 0);
|
||||||
|
assert_eq!(progress.progress_state, HealProgressState::Unknown);
|
||||||
|
progress.mark_completed();
|
||||||
|
assert!(progress.is_completed());
|
||||||
|
assert_eq!(progress.progress_state, HealProgressState::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_updates_do_not_double_count_object_outcomes() {
|
||||||
|
let mut progress = HealProgress::new();
|
||||||
|
progress.update_object_progress(2, 1, 0, 1, 256);
|
||||||
|
progress.update_stage(3, 4);
|
||||||
|
assert_eq!(progress.kind, HealProgressKind::ObjectSweep);
|
||||||
|
assert_eq!(progress.objects_scanned, 2);
|
||||||
|
assert_eq!(progress.objects_healed, 1);
|
||||||
|
assert_eq!(progress.skipped_objects, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_heal_statistics_new() {
|
fn test_heal_statistics_new() {
|
||||||
let stats = HealStatistics::new();
|
let stats = HealStatistics::new();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ mod checkpoint;
|
|||||||
mod replacement;
|
mod replacement;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use checkpoint::{CheckpointManager, ResumeCheckpoint};
|
pub use checkpoint::{CheckpointManager, CheckpointObjectOutcome, ResumeCheckpoint};
|
||||||
pub(crate) use replacement::replacement_target_identities_match;
|
pub(crate) use replacement::replacement_target_identities_match;
|
||||||
use replacement::replacement_targets_match_identities;
|
use replacement::replacement_targets_match_identities;
|
||||||
pub use replacement::{
|
pub use replacement::{
|
||||||
@@ -340,6 +340,12 @@ pub struct ResumeState {
|
|||||||
pub failed_objects: u64,
|
pub failed_objects: u64,
|
||||||
/// skipped objects
|
/// skipped objects
|
||||||
pub skipped_objects: u64,
|
pub skipped_objects: u64,
|
||||||
|
/// Terminal versions skipped because they were newer than the heal start.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_new_versions: u64,
|
||||||
|
/// Terminal versions handed to lifecycle expiry.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_ilm_expired: u64,
|
||||||
/// current bucket
|
/// current bucket
|
||||||
pub current_bucket: Option<String>,
|
pub current_bucket: Option<String>,
|
||||||
/// current object
|
/// current object
|
||||||
@@ -354,6 +360,24 @@ pub struct ResumeState {
|
|||||||
pub retry_count: u32,
|
pub retry_count: u32,
|
||||||
/// max retries
|
/// max retries
|
||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
|
/// Bytes accounted by the object ledger; additive for old snapshots.
|
||||||
|
#[serde(default)]
|
||||||
|
pub processed_bytes: u64,
|
||||||
|
/// Total bytes from a complete usage snapshot, when available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_bytes: u64,
|
||||||
|
/// Generation of the usage snapshot used for the baseline.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_generation: Option<u64>,
|
||||||
|
/// Whether the usage baseline is known. Missing in old snapshots means
|
||||||
|
/// indeterminate rather than a measured zero baseline.
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_known: bool,
|
||||||
|
/// Persistent telemetry fence for counter/byte overflow or corruption.
|
||||||
|
/// It must survive a restart so a saturated snapshot is never presented as
|
||||||
|
/// a measured percentage on the next resume.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_unknown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResumeState {
|
impl ResumeState {
|
||||||
@@ -377,6 +401,8 @@ impl ResumeState {
|
|||||||
successful_objects: 0,
|
successful_objects: 0,
|
||||||
failed_objects: 0,
|
failed_objects: 0,
|
||||||
skipped_objects: 0,
|
skipped_objects: 0,
|
||||||
|
skipped_new_versions: 0,
|
||||||
|
skipped_ilm_expired: 0,
|
||||||
current_bucket: None,
|
current_bucket: None,
|
||||||
current_object: None,
|
current_object: None,
|
||||||
completed_buckets: Vec::new(),
|
completed_buckets: Vec::new(),
|
||||||
@@ -384,6 +410,11 @@ impl ResumeState {
|
|||||||
error_message: None,
|
error_message: None,
|
||||||
retry_count: 0,
|
retry_count: 0,
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
baseline_generation: None,
|
||||||
|
baseline_known: false,
|
||||||
|
counter_unknown: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +443,39 @@ impl ResumeState {
|
|||||||
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_progress_with_bytes(
|
||||||
|
&mut self,
|
||||||
|
processed: u64,
|
||||||
|
successful: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
|
processed_bytes: u64,
|
||||||
|
) {
|
||||||
|
self.update_progress(processed, successful, failed, skipped);
|
||||||
|
self.processed_bytes = processed_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skipped_version_counts(&mut self, new_versions: u64, ilm_expired: u64) {
|
||||||
|
self.skipped_new_versions = new_versions;
|
||||||
|
self.skipped_ilm_expired = ilm_expired;
|
||||||
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_progress_baseline(&mut self, total_objects: u64, total_bytes: u64, generation: Option<u64>) {
|
||||||
|
self.total_objects = total_objects;
|
||||||
|
self.total_bytes = total_bytes;
|
||||||
|
self.baseline_generation = generation;
|
||||||
|
// This method is called only after a complete usage snapshot has been
|
||||||
|
// validated. A complete but empty snapshot is still a known baseline.
|
||||||
|
self.baseline_known = true;
|
||||||
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_counter_unknown(&mut self) {
|
||||||
|
self.counter_unknown = true;
|
||||||
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_current_item(&mut self, bucket: Option<String>, object: Option<String>) {
|
pub fn set_current_item(&mut self, bucket: Option<String>, object: Option<String>) {
|
||||||
self.current_bucket = bucket;
|
self.current_bucket = bucket;
|
||||||
self.current_object = object;
|
self.current_object = object;
|
||||||
@@ -437,6 +501,7 @@ impl ResumeState {
|
|||||||
if let Some(pos) = self.pending_buckets.iter().position(|b| b == bucket) {
|
if let Some(pos) = self.pending_buckets.iter().position(|b| b == bucket) {
|
||||||
self.pending_buckets.remove(pos);
|
self.pending_buckets.remove(pos);
|
||||||
}
|
}
|
||||||
|
self.resume_cursor = None;
|
||||||
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
self.last_update = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +519,10 @@ impl ResumeState {
|
|||||||
self.successful_objects = 0;
|
self.successful_objects = 0;
|
||||||
self.failed_objects = 0;
|
self.failed_objects = 0;
|
||||||
self.skipped_objects = 0;
|
self.skipped_objects = 0;
|
||||||
|
self.skipped_new_versions = 0;
|
||||||
|
self.skipped_ilm_expired = 0;
|
||||||
|
self.processed_bytes = 0;
|
||||||
|
self.counter_unknown = false;
|
||||||
self.completed = false;
|
self.completed = false;
|
||||||
// A retry re-scans every bucket from the beginning, so the version
|
// A retry re-scans every bucket from the beginning, so the version
|
||||||
// cursor must be cleared too — otherwise the retry would resume mid-scan.
|
// cursor must be cleared too — otherwise the retry would resume mid-scan.
|
||||||
@@ -476,14 +545,28 @@ impl ResumeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_progress_percentage(&self) -> f64 {
|
pub fn get_progress_percentage(&self) -> f64 {
|
||||||
|
if self.completed {
|
||||||
|
return 100.0;
|
||||||
|
}
|
||||||
|
if self.counter_unknown {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if !self.baseline_known {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if self.total_bytes > 0 {
|
||||||
|
return ((self.processed_bytes as f64 / self.total_bytes as f64) * 100.0).min(99.999);
|
||||||
|
}
|
||||||
if self.total_objects == 0 {
|
if self.total_objects == 0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
(self.processed_objects as f64 / self.total_objects as f64) * 100.0
|
((self.processed_objects as f64 / self.total_objects as f64) * 100.0).min(99.999)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_success_rate(&self) -> f64 {
|
pub fn get_success_rate(&self) -> f64 {
|
||||||
let total = self.successful_objects + self.failed_objects;
|
let Some(total) = self.successful_objects.checked_add(self.failed_objects) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
@@ -754,6 +837,14 @@ impl ResumeManager {
|
|||||||
state.successful_objects = 0;
|
state.successful_objects = 0;
|
||||||
state.failed_objects = 0;
|
state.failed_objects = 0;
|
||||||
state.skipped_objects = 0;
|
state.skipped_objects = 0;
|
||||||
|
state.skipped_new_versions = 0;
|
||||||
|
state.skipped_ilm_expired = 0;
|
||||||
|
state.processed_bytes = 0;
|
||||||
|
state.total_objects = 0;
|
||||||
|
state.total_bytes = 0;
|
||||||
|
state.baseline_generation = None;
|
||||||
|
state.baseline_known = false;
|
||||||
|
state.counter_unknown = false;
|
||||||
state.completed = false;
|
state.completed = false;
|
||||||
state.completed_buckets.clear();
|
state.completed_buckets.clear();
|
||||||
state.schema_version = CURRENT_RESUME_SCHEMA;
|
state.schema_version = CURRENT_RESUME_SCHEMA;
|
||||||
@@ -838,6 +929,41 @@ impl ResumeManager {
|
|||||||
self.save_state_throttled().await
|
self.save_state_throttled().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_progress_with_bytes(
|
||||||
|
&self,
|
||||||
|
processed: u64,
|
||||||
|
successful: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
|
processed_bytes: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.update_progress_with_bytes(processed, successful, failed, skipped, processed_bytes);
|
||||||
|
drop(state);
|
||||||
|
self.save_state_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_progress_baseline(&self, total_objects: u64, total_bytes: u64, generation: Option<u64>) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_progress_baseline(total_objects, total_bytes, generation);
|
||||||
|
drop(state);
|
||||||
|
self.save_state_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_counter_unknown(&self) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.mark_counter_unknown();
|
||||||
|
drop(state);
|
||||||
|
self.save_state().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_skipped_version_counts(&self, new_versions: u64, ilm_expired: u64) -> Result<()> {
|
||||||
|
let mut state = self.state.write().await;
|
||||||
|
state.set_skipped_version_counts(new_versions, ilm_expired);
|
||||||
|
drop(state);
|
||||||
|
self.save_state_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Set current item. Called once per healed object, so persistence is
|
/// Set current item. Called once per healed object, so persistence is
|
||||||
/// throttled: the in-memory state always updates, but the snapshot is only
|
/// throttled: the in-memory state always updates, but the snapshot is only
|
||||||
/// written every `PERSIST_EVERY_MUTATIONS` calls or `PERSIST_INTERVAL`.
|
/// written every `PERSIST_EVERY_MUTATIONS` calls or `PERSIST_INTERVAL`.
|
||||||
@@ -882,7 +1008,7 @@ impl ResumeManager {
|
|||||||
let mut state = self.state.write().await;
|
let mut state = self.state.write().await;
|
||||||
state.complete_bucket(bucket);
|
state.complete_bucket(bucket);
|
||||||
drop(state);
|
drop(state);
|
||||||
self.save_state_throttled().await
|
self.save_state().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// mark task completed
|
/// mark task completed
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const EVENT_HEAL_CHECKPOINT_STATE: &str = "heal_checkpoint_state";
|
|||||||
/// to the new `compose_key` identities, so a stale checkpoint is discarded.
|
/// to the new `compose_key` identities, so a stale checkpoint is discarded.
|
||||||
pub(super) const CURRENT_CHECKPOINT_SCHEMA: u32 = 5;
|
pub(super) const CURRENT_CHECKPOINT_SCHEMA: u32 = 5;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum CheckpointObjectOutcome {
|
||||||
|
Processed,
|
||||||
|
Failed,
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
/// resume checkpoint
|
/// resume checkpoint
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ResumeCheckpoint {
|
pub struct ResumeCheckpoint {
|
||||||
@@ -57,6 +64,30 @@ pub struct ResumeCheckpoint {
|
|||||||
pub failed_objects: HashSet<String>,
|
pub failed_objects: HashSet<String>,
|
||||||
/// skipped objects
|
/// skipped objects
|
||||||
pub skipped_objects: HashSet<String>,
|
pub skipped_objects: HashSet<String>,
|
||||||
|
/// Aggregate object ledger counters restored alongside the dedup sets.
|
||||||
|
#[serde(default)]
|
||||||
|
pub successful_objects: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub failed_object_count: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_object_count: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_new_versions: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skipped_ilm_expired: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub processed_bytes: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_objects: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_bytes: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_generation: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub baseline_known: bool,
|
||||||
|
/// Persistent telemetry fence for counter/byte overflow or corruption.
|
||||||
|
#[serde(default)]
|
||||||
|
pub counter_unknown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResumeCheckpoint {
|
impl ResumeCheckpoint {
|
||||||
@@ -70,6 +101,17 @@ impl ResumeCheckpoint {
|
|||||||
processed_objects: HashSet::new(),
|
processed_objects: HashSet::new(),
|
||||||
failed_objects: HashSet::new(),
|
failed_objects: HashSet::new(),
|
||||||
skipped_objects: HashSet::new(),
|
skipped_objects: HashSet::new(),
|
||||||
|
successful_objects: 0,
|
||||||
|
failed_object_count: 0,
|
||||||
|
skipped_object_count: 0,
|
||||||
|
skipped_new_versions: 0,
|
||||||
|
skipped_ilm_expired: 0,
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_objects: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
baseline_generation: None,
|
||||||
|
baseline_known: false,
|
||||||
|
counter_unknown: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +133,34 @@ impl ResumeCheckpoint {
|
|||||||
self.skipped_objects.insert(object);
|
self.skipped_objects.insert(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_progress(&mut self, successful: u64, failed: u64, skipped: u64, bytes: u64) {
|
||||||
|
self.successful_objects = successful;
|
||||||
|
self.failed_object_count = failed;
|
||||||
|
self.skipped_object_count = skipped;
|
||||||
|
self.processed_bytes = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_progress_baseline(&mut self, total_objects: u64, total_bytes: u64, generation: Option<u64>) {
|
||||||
|
self.total_objects = total_objects;
|
||||||
|
self.total_bytes = total_bytes;
|
||||||
|
self.baseline_generation = generation;
|
||||||
|
// The caller has already validated that this is a complete snapshot;
|
||||||
|
// preserve the distinction between a known empty scope and an old
|
||||||
|
// checkpoint that omitted all baseline fields.
|
||||||
|
self.baseline_known = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_counter_unknown(&mut self) {
|
||||||
|
self.counter_unknown = true;
|
||||||
|
self.checkpoint_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_skipped_version_counts(&mut self, new_versions: u64, ilm_expired: u64) {
|
||||||
|
self.skipped_new_versions = new_versions;
|
||||||
|
self.skipped_ilm_expired = ilm_expired;
|
||||||
|
self.checkpoint_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
|
}
|
||||||
|
|
||||||
/// Advance past a fully-processed page: objects below `object_index` are
|
/// Advance past a fully-processed page: objects below `object_index` are
|
||||||
/// skipped by position on resume, so the per-object sets no longer need
|
/// skipped by position on resume, so the per-object sets no longer need
|
||||||
/// their entries and would otherwise grow with the whole bucket.
|
/// their entries and would otherwise grow with the whole bucket.
|
||||||
@@ -107,6 +177,17 @@ impl ResumeCheckpoint {
|
|||||||
self.update_position(0, 0);
|
self.update_position(0, 0);
|
||||||
self.processed_objects.clear();
|
self.processed_objects.clear();
|
||||||
self.skipped_objects.clear();
|
self.skipped_objects.clear();
|
||||||
|
self.successful_objects = 0;
|
||||||
|
self.failed_object_count = 0;
|
||||||
|
self.skipped_object_count = 0;
|
||||||
|
self.skipped_new_versions = 0;
|
||||||
|
self.skipped_ilm_expired = 0;
|
||||||
|
self.processed_bytes = 0;
|
||||||
|
self.total_objects = 0;
|
||||||
|
self.total_bytes = 0;
|
||||||
|
self.baseline_generation = None;
|
||||||
|
self.baseline_known = false;
|
||||||
|
self.counter_unknown = false;
|
||||||
self.failed_objects.clear();
|
self.failed_objects.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +266,17 @@ impl CheckpointManager {
|
|||||||
checkpoint.processed_objects.clear();
|
checkpoint.processed_objects.clear();
|
||||||
checkpoint.failed_objects.clear();
|
checkpoint.failed_objects.clear();
|
||||||
checkpoint.skipped_objects.clear();
|
checkpoint.skipped_objects.clear();
|
||||||
|
checkpoint.successful_objects = 0;
|
||||||
|
checkpoint.failed_object_count = 0;
|
||||||
|
checkpoint.skipped_object_count = 0;
|
||||||
|
checkpoint.skipped_new_versions = 0;
|
||||||
|
checkpoint.skipped_ilm_expired = 0;
|
||||||
|
checkpoint.processed_bytes = 0;
|
||||||
|
checkpoint.total_objects = 0;
|
||||||
|
checkpoint.total_bytes = 0;
|
||||||
|
checkpoint.baseline_generation = None;
|
||||||
|
checkpoint.baseline_known = false;
|
||||||
|
checkpoint.counter_unknown = false;
|
||||||
checkpoint.current_bucket_index = 0;
|
checkpoint.current_bucket_index = 0;
|
||||||
checkpoint.current_object_index = 0;
|
checkpoint.current_object_index = 0;
|
||||||
checkpoint.schema_version = CURRENT_CHECKPOINT_SCHEMA;
|
checkpoint.schema_version = CURRENT_CHECKPOINT_SCHEMA;
|
||||||
@@ -225,7 +317,7 @@ impl CheckpointManager {
|
|||||||
self.save_checkpoint_throttled().await
|
self.save_checkpoint_throttled().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance past a completed page and prune the per-object sets, then persist.
|
/// Persist a completed page position while retaining its identities.
|
||||||
pub async fn complete_page(&self, bucket_index: usize, object_index: usize) -> Result<()> {
|
pub async fn complete_page(&self, bucket_index: usize, object_index: usize) -> Result<()> {
|
||||||
let mut checkpoint = self.checkpoint.write().await;
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
checkpoint.complete_page(bucket_index, object_index);
|
checkpoint.complete_page(bucket_index, object_index);
|
||||||
@@ -233,6 +325,35 @@ impl CheckpointManager {
|
|||||||
self.save_checkpoint_throttled().await
|
self.save_checkpoint_throttled().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the page position while retaining identities until the resume
|
||||||
|
/// cursor is durable.
|
||||||
|
pub async fn advance_page(&self, bucket_index: usize, object_index: usize) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.update_position(bucket_index, object_index);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the previous page's dedup identities only after its resume cursor
|
||||||
|
/// has been durably exposed.
|
||||||
|
pub async fn prune_completed_page(&self) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.processed_objects.clear();
|
||||||
|
checkpoint.skipped_objects.clear();
|
||||||
|
checkpoint.failed_objects.clear();
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance to the next bucket and clear the final page identities after the
|
||||||
|
/// resume state has durably recorded the completed bucket.
|
||||||
|
pub async fn complete_bucket(&self, next_bucket_index: usize) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.complete_page(next_bucket_index, 0);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset the checkpoint to the start of the scan for a retry, then persist.
|
/// Reset the checkpoint to the start of the scan for a retry, then persist.
|
||||||
pub async fn reset_for_retry(&self) -> Result<()> {
|
pub async fn reset_for_retry(&self) -> Result<()> {
|
||||||
let mut checkpoint = self.checkpoint.write().await;
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
@@ -267,6 +388,62 @@ impl CheckpointManager {
|
|||||||
self.save_checkpoint_if_due().await
|
self.save_checkpoint_if_due().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically persist an object's dedup identity with its aggregate result.
|
||||||
|
pub async fn record_object_outcome(
|
||||||
|
&self,
|
||||||
|
object: String,
|
||||||
|
outcome: CheckpointObjectOutcome,
|
||||||
|
successful: u64,
|
||||||
|
failed: u64,
|
||||||
|
skipped: u64,
|
||||||
|
bytes: u64,
|
||||||
|
skipped_new_versions: u64,
|
||||||
|
skipped_ilm_expired: u64,
|
||||||
|
counter_unknown: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
match outcome {
|
||||||
|
CheckpointObjectOutcome::Processed => checkpoint.add_processed_object(object),
|
||||||
|
CheckpointObjectOutcome::Failed => checkpoint.add_failed_object(object),
|
||||||
|
CheckpointObjectOutcome::Skipped => checkpoint.add_skipped_object(object),
|
||||||
|
}
|
||||||
|
checkpoint.update_progress(successful, failed, skipped, bytes);
|
||||||
|
checkpoint.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired);
|
||||||
|
if counter_unknown {
|
||||||
|
checkpoint.mark_counter_unknown();
|
||||||
|
}
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_if_due().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_progress(&self, successful: u64, failed: u64, skipped: u64, bytes: u64) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.update_progress(successful, failed, skipped, bytes);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_if_due().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_progress_baseline(&self, total_objects: u64, total_bytes: u64, generation: Option<u64>) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.set_progress_baseline(total_objects, total_bytes, generation);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_counter_unknown(&self) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.mark_counter_unknown();
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_skipped_version_counts(&self, new_versions: u64, ilm_expired: u64) -> Result<()> {
|
||||||
|
let mut checkpoint = self.checkpoint.write().await;
|
||||||
|
checkpoint.set_skipped_version_counts(new_versions, ilm_expired);
|
||||||
|
drop(checkpoint);
|
||||||
|
self.save_checkpoint_throttled().await
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_checkpoint_if_due(&self) -> Result<()> {
|
async fn save_checkpoint_if_due(&self) -> Result<()> {
|
||||||
let should_save = self.throttle.lock().map(|mut throttle| throttle.record()).unwrap_or(true);
|
let should_save = self.throttle.lock().map(|mut throttle| throttle.record()).unwrap_or(true);
|
||||||
if !should_save {
|
if !should_save {
|
||||||
|
|||||||
@@ -1296,6 +1296,7 @@ async fn test_resume_state_progress() {
|
|||||||
assert_eq!(progress, 0.0); // total_objects is 0
|
assert_eq!(progress, 0.0); // total_objects is 0
|
||||||
|
|
||||||
state.total_objects = 100;
|
state.total_objects = 100;
|
||||||
|
state.baseline_known = true;
|
||||||
let progress = state.get_progress_percentage();
|
let progress = state.get_progress_percentage();
|
||||||
assert_eq!(progress, 10.0);
|
assert_eq!(progress, 10.0);
|
||||||
}
|
}
|
||||||
@@ -1475,6 +1476,40 @@ fn test_checkpoint_object_sets_dedupe_and_prune() {
|
|||||||
assert!(checkpoint.failed_objects.is_empty());
|
assert!(checkpoint.failed_objects.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkpoint_page_commit_keeps_ledger_until_cursor_is_durable() {
|
||||||
|
let (_temp_dir, disk) = schema_test_disk().await;
|
||||||
|
let task_id = ResumeUtils::generate_task_id();
|
||||||
|
let checkpoint = CheckpointManager::new(disk.clone(), task_id.clone()).await.unwrap();
|
||||||
|
|
||||||
|
checkpoint
|
||||||
|
.record_object_outcome(
|
||||||
|
"bucket/object:v1".to_string(),
|
||||||
|
CheckpointObjectOutcome::Processed,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
128,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
checkpoint.advance_page(0, 1).await.unwrap();
|
||||||
|
|
||||||
|
let reloaded = CheckpointManager::load_from_disk(disk.clone(), &task_id).await.unwrap();
|
||||||
|
let snapshot = reloaded.get_checkpoint().await;
|
||||||
|
assert_eq!(snapshot.current_object_index, 1);
|
||||||
|
assert_eq!(snapshot.successful_objects, 1);
|
||||||
|
assert_eq!(snapshot.processed_bytes, 128);
|
||||||
|
assert!(snapshot.processed_objects.contains("bucket/object:v1"));
|
||||||
|
|
||||||
|
checkpoint.prune_completed_page().await.unwrap();
|
||||||
|
let reloaded = CheckpointManager::load_from_disk(disk, &task_id).await.unwrap();
|
||||||
|
assert!(reloaded.get_checkpoint().await.processed_objects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_checkpoint_loads_legacy_vec_format() {
|
fn test_checkpoint_loads_legacy_vec_format() {
|
||||||
// Checkpoints written before the HashSet migration stored the object
|
// Checkpoints written before the HashSet migration stored the object
|
||||||
@@ -1639,6 +1674,120 @@ async fn current_normal_resume_schema_preserves_progress() {
|
|||||||
temp_dir.close().expect("remove schema test directory");
|
temp_dir.close().expect("remove schema test directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_checkpoint_restores_bytes_and_generation() {
|
||||||
|
let mut checkpoint = ResumeCheckpoint::new("progress-checkpoint".to_string());
|
||||||
|
checkpoint.set_progress_baseline(9, 4096, Some(77));
|
||||||
|
checkpoint.update_progress(4, 1, 2, 2048);
|
||||||
|
checkpoint.set_skipped_version_counts(3, 1);
|
||||||
|
checkpoint.mark_counter_unknown();
|
||||||
|
|
||||||
|
let restored: ResumeCheckpoint =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&checkpoint).expect("serialize checkpoint")).expect("deserialize checkpoint");
|
||||||
|
assert_eq!(restored.processed_bytes, 2048);
|
||||||
|
assert_eq!(restored.total_objects, 9);
|
||||||
|
assert_eq!(restored.total_bytes, 4096);
|
||||||
|
assert_eq!(restored.baseline_generation, Some(77));
|
||||||
|
assert!(restored.baseline_known);
|
||||||
|
assert_eq!(restored.skipped_new_versions, 3);
|
||||||
|
assert_eq!(restored.skipped_ilm_expired, 1);
|
||||||
|
assert!(restored.counter_unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_progress_schema_migrates_missing_fields_to_unknown() {
|
||||||
|
let state = ResumeState::new(
|
||||||
|
"legacy-progress".to_string(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
let mut value = serde_json::to_value(state).expect("serialize legacy-compatible state");
|
||||||
|
let object = value.as_object_mut().expect("state must be an object");
|
||||||
|
for field in [
|
||||||
|
"processed_bytes",
|
||||||
|
"total_bytes",
|
||||||
|
"baseline_generation",
|
||||||
|
"baseline_known",
|
||||||
|
"skipped_new_versions",
|
||||||
|
"skipped_ilm_expired",
|
||||||
|
] {
|
||||||
|
object.remove(field);
|
||||||
|
}
|
||||||
|
object.insert("total_objects".to_string(), serde_json::json!(10));
|
||||||
|
object.insert("processed_objects".to_string(), serde_json::json!(5));
|
||||||
|
let restored: ResumeState = serde_json::from_value(value).expect("deserialize old progress state");
|
||||||
|
assert_eq!(restored.processed_bytes, 0);
|
||||||
|
assert_eq!(restored.total_bytes, 0);
|
||||||
|
assert_eq!(restored.baseline_generation, None);
|
||||||
|
assert!(!restored.baseline_known, "missing baseline must remain unknown");
|
||||||
|
assert_eq!(restored.get_progress_percentage(), 0.0);
|
||||||
|
assert_eq!(restored.skipped_new_versions, 0);
|
||||||
|
assert_eq!(restored.skipped_ilm_expired, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_counter_unknown_survives_resume_round_trip() {
|
||||||
|
let mut state = ResumeState::new(
|
||||||
|
"overflow-progress".to_string(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
state.mark_counter_unknown();
|
||||||
|
|
||||||
|
let restored: ResumeState =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&state).expect("serialize resume state")).expect("deserialize resume state");
|
||||||
|
assert!(restored.counter_unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkpoint_progress_survives_a_torn_resume_summary_write() {
|
||||||
|
let (_temp_dir, disk) = schema_test_disk().await;
|
||||||
|
let task_id = ResumeUtils::generate_task_id();
|
||||||
|
let _resume = ResumeManager::new(
|
||||||
|
disk.clone(),
|
||||||
|
task_id.clone(),
|
||||||
|
"erasure_set".to_string(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
vec!["bucket".to_string()],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("resume state should persist");
|
||||||
|
let checkpoint = CheckpointManager::new(disk.clone(), task_id.clone())
|
||||||
|
.await
|
||||||
|
.expect("checkpoint should persist");
|
||||||
|
|
||||||
|
// This is the ordering used by the erasure-set loop: the checkpoint is
|
||||||
|
// durable before the summary write. Stop here to model a crash in the
|
||||||
|
// inter-store window and verify that the recovery authority retains the
|
||||||
|
// telemetry fence and bytes.
|
||||||
|
checkpoint
|
||||||
|
.update_progress(3, 0, 0, 1024)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint progress should persist");
|
||||||
|
checkpoint.mark_counter_unknown().await.expect("unknown fence should persist");
|
||||||
|
checkpoint
|
||||||
|
.update_position(0, 3)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint position should persist");
|
||||||
|
|
||||||
|
let restored_checkpoint = CheckpointManager::load_from_disk(disk.clone(), &task_id)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint should reload")
|
||||||
|
.get_checkpoint()
|
||||||
|
.await;
|
||||||
|
let restored_resume = ResumeManager::load_from_disk(disk, &task_id)
|
||||||
|
.await
|
||||||
|
.expect("resume summary should reload")
|
||||||
|
.get_state()
|
||||||
|
.await;
|
||||||
|
assert!(restored_checkpoint.counter_unknown);
|
||||||
|
assert_eq!(restored_checkpoint.processed_bytes, 1024);
|
||||||
|
assert_eq!(restored_checkpoint.current_object_index, 3);
|
||||||
|
assert!(!restored_resume.counter_unknown, "summary is intentionally the torn/older store");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn future_resume_and_checkpoint_schemas_are_rejected() {
|
async fn future_resume_and_checkpoint_schemas_are_rejected() {
|
||||||
let (temp_dir, disk) = schema_test_disk().await;
|
let (temp_dir, disk) = schema_test_disk().await;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|||||||
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
||||||
use rustfs_madmin::heal_commands::HealResultItem;
|
use rustfs_madmin::heal_commands::HealResultItem;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
@@ -34,6 +36,9 @@ pub use super::{HealObjectInfo, HealObjectOptions, HealPutObjReader};
|
|||||||
pub struct HealBucketUsageBaseline {
|
pub struct HealBucketUsageBaseline {
|
||||||
pub objects_count: u64,
|
pub objects_count: u64,
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
|
/// Stable identity of the validated usage snapshot and selected scope.
|
||||||
|
/// `None` is retained for test/legacy providers that cannot expose one.
|
||||||
|
pub generation: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct HealLifecycleExpiryContext {
|
pub struct HealLifecycleExpiryContext {
|
||||||
@@ -785,11 +790,30 @@ impl HealStorageAPI for ECStoreHealStorage {
|
|||||||
let mut baseline = HealBucketUsageBaseline::default();
|
let mut baseline = HealBucketUsageBaseline::default();
|
||||||
for bucket in buckets {
|
for bucket in buckets {
|
||||||
if let Some(usage) = info.buckets_usage.get(bucket) {
|
if let Some(usage) = info.buckets_usage.get(bucket) {
|
||||||
baseline.objects_count = baseline.objects_count.saturating_add(usage.objects_count);
|
baseline.objects_count = match baseline.objects_count.checked_add(usage.objects_count) {
|
||||||
baseline.bytes = baseline.bytes.saturating_add(usage.size);
|
Some(total) => total,
|
||||||
|
// A corrupt/overflowing usage snapshot is not a usable
|
||||||
|
// denominator. Leave progress indeterminate instead of
|
||||||
|
// turning saturation into a plausible percentage.
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
baseline.bytes = match baseline.bytes.checked_add(usage.size) {
|
||||||
|
Some(total) => total,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let identity = info.snapshot_identity();
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
identity.last_update.hash(&mut hasher);
|
||||||
|
identity.scanner_cycle.hash(&mut hasher);
|
||||||
|
identity.scanner_epoch.hash(&mut hasher);
|
||||||
|
let mut scope = buckets.to_vec();
|
||||||
|
scope.sort_unstable();
|
||||||
|
scope.hash(&mut hasher);
|
||||||
|
baseline.generation = Some(hasher.finish());
|
||||||
|
|
||||||
Ok(Some(baseline))
|
Ok(Some(baseline))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ impl HealTask {
|
|||||||
|
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
||||||
progress.update_progress(0, 1, 0, 0);
|
progress.update_stage(1, 1);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +733,7 @@ impl HealTask {
|
|||||||
"Heal object skipped for data usage cache after transient error"
|
"Heal object skipped for data usage cache after transient error"
|
||||||
);
|
);
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,7 +757,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("skipped: {bucket}/{object}")));
|
||||||
progress.update_progress(4, 4, 0, 0);
|
progress.update_stage(4, 4);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,6 +831,10 @@ impl HealTask {
|
|||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
// A stage can reach its final step before the durable resume
|
||||||
|
// ledger and cleanup fences commit. Publish terminal 100 only
|
||||||
|
// after the enclosing operation has returned success.
|
||||||
|
self.progress.write().await.mark_completed();
|
||||||
let mut status = self.status.write().await;
|
let mut status = self.status.write().await;
|
||||||
*status = HealTaskStatus::Completed;
|
*status = HealTaskStatus::Completed;
|
||||||
demote_to_debug_when!(self.heal_type.is_per_object(), info, target: "rustfs::heal::task", {
|
demote_to_debug_when!(self.heal_type.is_per_object(), info, target: "rustfs::heal::task", {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
/// bucket/cluster/prefix heal: the recursive bucket-objects sweep and the erasure-set usage baseline
|
/// bucket/cluster/prefix heal: the recursive bucket-objects sweep and the erasure-set usage baseline
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::heal::progress::{add_bytes, increment_counter};
|
||||||
|
|
||||||
impl HealTask {
|
impl HealTask {
|
||||||
pub(super) async fn heal_bucket(&self, bucket: &str) -> Result<()> {
|
pub(super) async fn heal_bucket(&self, bucket: &str) -> Result<()> {
|
||||||
@@ -32,7 +33,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("bucket: {bucket}")));
|
progress.set_current_object(Some(format!("bucket: {bucket}")));
|
||||||
progress.update_progress(0, 3, 0, 0);
|
progress.update_stage(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if bucket exists
|
// Step 1: Check if bucket exists
|
||||||
@@ -66,7 +67,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Perform bucket heal using ecstore
|
// Step 2: Perform bucket heal using ecstore
|
||||||
@@ -122,7 +123,7 @@ impl HealTask {
|
|||||||
|
|
||||||
if !self.options.recursive {
|
if !self.options.recursive {
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Err(Error::TaskExecutionFailed {
|
Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal bucket {bucket}: {e}"),
|
message: format!("Failed to heal bucket {bucket}: {e}"),
|
||||||
@@ -245,6 +246,7 @@ impl HealTask {
|
|||||||
let mut scanned = 0u64;
|
let mut scanned = 0u64;
|
||||||
let mut healed = 0u64;
|
let mut healed = 0u64;
|
||||||
let mut failed = 0u64;
|
let mut failed = 0u64;
|
||||||
|
let mut skipped = 0u64;
|
||||||
let mut retryable_failed = 0u64;
|
let mut retryable_failed = 0u64;
|
||||||
let mut permanent_failed = 0u64;
|
let mut permanent_failed = 0u64;
|
||||||
let mut bytes = 0u64;
|
let mut bytes = 0u64;
|
||||||
@@ -286,16 +288,14 @@ impl HealTask {
|
|||||||
let mut retry = Vec::with_capacity(pending.len());
|
let mut retry = Vec::with_capacity(pending.len());
|
||||||
for item in pending {
|
for item in pending {
|
||||||
self.check_control_flags().await?;
|
self.check_control_flags().await?;
|
||||||
|
let mut telemetry_unknown = false;
|
||||||
let object = item.name.as_str();
|
let object = item.name.as_str();
|
||||||
if retry_attempt == 0 {
|
|
||||||
scanned = scanned.saturating_add(1);
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||||
progress.update_progress(scanned, healed, failed, bytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut terminal_outcome = true;
|
||||||
let error = match self
|
let error = match self
|
||||||
.await_with_control(
|
.await_with_control(
|
||||||
self.storage
|
self.storage
|
||||||
@@ -304,13 +304,13 @@ impl HealTask {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((result, None)) => {
|
Ok((result, None)) => {
|
||||||
healed = healed.saturating_add(1);
|
telemetry_unknown |= !increment_counter(&mut healed);
|
||||||
bytes = bytes.saturating_add(u64::try_from(result.object_size).unwrap_or_default());
|
telemetry_unknown |= !add_bytes(&mut bytes, u64::try_from(result.object_size).unwrap_or(u64::MAX));
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Ok((_, Some(err))) if is_missing_object_dir_heal_result(object, &err) => {
|
Ok((_, Some(err))) if is_missing_object_dir_heal_result(object, &err) => {
|
||||||
healed = healed.saturating_add(1);
|
telemetry_unknown |= !increment_counter(&mut healed);
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
@@ -329,6 +329,7 @@ impl HealTask {
|
|||||||
|
|
||||||
if let Some(err) = error {
|
if let Some(err) = error {
|
||||||
if Self::should_skip_data_usage_cache_heal_error(bucket, object, &err) {
|
if Self::should_skip_data_usage_cache_heal_error(bucket, object, &err) {
|
||||||
|
telemetry_unknown |= !increment_counter(&mut skipped);
|
||||||
warn!(
|
warn!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
@@ -342,6 +343,7 @@ impl HealTask {
|
|||||||
"Heal bucket object repair skipped due to transient metadata error"
|
"Heal bucket object repair skipped due to transient metadata error"
|
||||||
);
|
);
|
||||||
} else if err.is_recoverable_heal() && retry_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
} else if err.is_recoverable_heal() && retry_attempt < MAX_BUCKET_OBJECT_HEAL_RETRIES {
|
||||||
|
terminal_outcome = false;
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
event = EVENT_HEAL_BUCKET_RESULT,
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
@@ -357,7 +359,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
retry.push(item);
|
retry.push(item);
|
||||||
} else {
|
} else {
|
||||||
failed = failed.saturating_add(1);
|
telemetry_unknown |= !increment_counter(&mut failed);
|
||||||
if err.is_recoverable_heal() {
|
if err.is_recoverable_heal() {
|
||||||
retryable_failed = retryable_failed.saturating_add(1);
|
retryable_failed = retryable_failed.saturating_add(1);
|
||||||
} else {
|
} else {
|
||||||
@@ -383,8 +385,19 @@ impl HealTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if terminal_outcome {
|
||||||
|
telemetry_unknown |= !increment_counter(&mut scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !terminal_outcome {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(scanned, healed, failed, bytes);
|
progress.update_object_progress(scanned, healed, failed, skipped, bytes);
|
||||||
|
if telemetry_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pending = retry;
|
pending = retry;
|
||||||
retry_attempt = retry_attempt.saturating_add(1);
|
retry_attempt = retry_attempt.saturating_add(1);
|
||||||
@@ -431,7 +444,7 @@ impl HealTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn apply_erasure_set_usage_baseline(&self, buckets: &[String]) -> Result<()> {
|
pub(super) async fn apply_erasure_set_usage_baseline(&self, buckets: &[String], set_disk_id: &str) -> Result<()> {
|
||||||
let baseline = match self
|
let baseline = match self
|
||||||
.await_with_control(self.storage.erasure_set_usage_baseline(buckets))
|
.await_with_control(self.storage.erasure_set_usage_baseline(buckets))
|
||||||
.await
|
.await
|
||||||
@@ -442,9 +455,26 @@ impl HealTask {
|
|||||||
Err(_) => return Ok(()),
|
Err(_) => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let HealBucketUsageBaseline { objects_count, bytes } = baseline;
|
let HealBucketUsageBaseline {
|
||||||
|
objects_count,
|
||||||
|
bytes,
|
||||||
|
generation,
|
||||||
|
} = baseline;
|
||||||
|
let generation = generation.map(|snapshot_generation| {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
snapshot_generation.hash(&mut hasher);
|
||||||
|
set_disk_id.hash(&mut hasher);
|
||||||
|
self.options.pool_index.hash(&mut hasher);
|
||||||
|
self.options.set_index.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
});
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_total_baseline(objects_count, bytes);
|
if let Some(generation) = generation {
|
||||||
|
progress.set_total_baseline_with_generation(objects_count, bytes, generation);
|
||||||
|
} else {
|
||||||
|
progress.set_total_baseline(objects_count, bytes);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("erasure_set: {} ({} buckets)", set_disk_id, buckets.len())));
|
progress.set_current_object(Some(format!("erasure_set: {} ({} buckets)", set_disk_id, buckets.len())));
|
||||||
progress.update_progress(0, 4, 0, 0);
|
progress.update_stage(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_auto_replacement = matches!(self.source, HealRequestSource::AutoHeal) && !self.heal_endpoints.is_empty();
|
let is_auto_replacement = matches!(self.source, HealRequestSource::AutoHeal) && !self.heal_endpoints.is_empty();
|
||||||
@@ -158,7 +158,7 @@ impl HealTask {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
self.apply_erasure_set_usage_baseline(&buckets).await?;
|
self.apply_erasure_set_usage_baseline(&buckets, &set_disk_id).await?;
|
||||||
|
|
||||||
let healing_marker = format!("{set_disk_id}:{}", self.id);
|
let healing_marker = format!("{set_disk_id}:{}", self.id);
|
||||||
if let Some((disk, resume_manager, _)) = replacement_resume.as_ref() {
|
if let Some((disk, resume_manager, _)) = replacement_resume.as_ref() {
|
||||||
@@ -244,7 +244,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(4, 4, 0, 0);
|
progress.update_stage(4, 4);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
||||||
@@ -297,7 +297,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(4, 4, 0, 0);
|
progress.update_stage(4, 4);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
message: format!("Failed to heal disk format for {set_disk_id}: {e}"),
|
||||||
@@ -307,7 +307,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 4, 0, 0);
|
progress.update_stage(1, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rebuilt disks are formatted now: mark them as healing so
|
// The rebuilt disks are formatted now: mark them as healing so
|
||||||
@@ -336,7 +336,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(2, 4, 0, 0);
|
progress.update_stage(2, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Heal bucket structure
|
// Step 3: Heal bucket structure
|
||||||
@@ -420,7 +420,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 4, 0, 0);
|
progress.update_stage(3, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Execute erasure set heal with resume
|
// Step 4: Execute erasure set heal with resume
|
||||||
@@ -463,9 +463,7 @@ impl HealTask {
|
|||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
self.progress.write().await.update_stage(4, 4);
|
||||||
let bytes_processed = progress.bytes_processed;
|
|
||||||
progress.update_progress(4, 4, 0, bytes_processed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("metadata: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("metadata: {bucket}/{object}")));
|
||||||
progress.update_progress(0, 3, 0, 0);
|
progress.update_stage(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if object exists
|
// Step 1: Check if object exists
|
||||||
@@ -74,7 +74,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Perform metadata heal using ecstore
|
// Step 2: Perform metadata heal using ecstore
|
||||||
@@ -122,7 +122,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
||||||
@@ -145,7 +145,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,7 +167,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Err(Error::TaskExecutionFailed {
|
Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
message: format!("Failed to heal metadata {bucket}/{object}: {e}"),
|
||||||
@@ -194,7 +194,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("ec_decode: {bucket}/{object}")));
|
progress.set_current_object(Some(format!("ec_decode: {bucket}/{object}")));
|
||||||
progress.update_progress(0, 3, 0, 0);
|
progress.update_stage(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if object exists
|
// Step 1: Check if object exists
|
||||||
@@ -236,7 +236,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Perform EC decode heal using ecstore
|
// Step 2: Perform EC decode heal using ecstore
|
||||||
@@ -284,7 +284,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Err(Error::TaskExecutionFailed {
|
return Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
||||||
@@ -309,7 +309,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -331,7 +331,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
Err(Error::TaskExecutionFailed {
|
Err(Error::TaskExecutionFailed {
|
||||||
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
message: format!("Failed to heal EC decode {bucket}/{object}: {e}"),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl HealTask {
|
|||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
progress.set_current_object(Some(format!("{bucket}/{object}")));
|
||||||
progress.update_progress(0, 4, 0, 0);
|
progress.update_stage(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if object exists and get metadata
|
// Step 1: Check if object exists and get metadata
|
||||||
@@ -132,7 +132,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(1, 3, 0, 0);
|
progress.update_stage(1, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: directly call ecstore to perform heal
|
// Step 2: directly call ecstore to perform heal
|
||||||
@@ -187,7 +187,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if Self::should_return_typed_heal_error(&e) {
|
if Self::should_return_typed_heal_error(&e) {
|
||||||
@@ -249,7 +249,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -275,7 +275,7 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -295,7 +295,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(3, 3, 0, 0);
|
progress.update_stage(3, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
if Self::should_return_typed_heal_error(&e) {
|
if Self::should_return_typed_heal_error(&e) {
|
||||||
@@ -414,7 +414,7 @@ impl HealTask {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_progress(4, 4, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -2096,6 +2096,7 @@ async fn erasure_set_heal_applies_usage_baseline_to_progress() {
|
|||||||
usage_baseline: Mutex::new(Some(HealBucketUsageBaseline {
|
usage_baseline: Mutex::new(Some(HealBucketUsageBaseline {
|
||||||
objects_count: 10,
|
objects_count: 10,
|
||||||
bytes: 8,
|
bytes: 8,
|
||||||
|
generation: Some(1),
|
||||||
})),
|
})),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -2119,6 +2120,8 @@ async fn erasure_set_heal_applies_usage_baseline_to_progress() {
|
|||||||
let progress = task.get_progress().await;
|
let progress = task.get_progress().await;
|
||||||
assert_eq!(progress.objects_total_count, 10);
|
assert_eq!(progress.objects_total_count, 10);
|
||||||
assert_eq!(progress.objects_total_size, 8);
|
assert_eq!(progress.objects_total_size, 8);
|
||||||
|
assert!(progress.baseline_generation.is_some());
|
||||||
|
assert!(progress.baseline_known);
|
||||||
assert_eq!(progress.bytes_processed, 2);
|
assert_eq!(progress.bytes_processed, 2);
|
||||||
assert!((progress.progress_percentage - 25.0).abs() < 0.001);
|
assert!((progress.progress_percentage - 25.0).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ pub const REPLICATION_READ_ONLY_HISTORICAL_FIELDS: &[&str] = &[
|
|||||||
"Destination.ReplicationTime",
|
"Destination.ReplicationTime",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION: u32 = 1;
|
// v2: disableProxy moved from unsupported to writable (per-target read-proxy
|
||||||
|
// opt-out is accepted by set-remote-target and the `proxy` update op).
|
||||||
|
pub const REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION: u32 = 2;
|
||||||
|
|
||||||
pub const REMOTE_TARGET_WRITABLE_FIELDS: &[&str] = &[
|
pub const REMOTE_TARGET_WRITABLE_FIELDS: &[&str] = &[
|
||||||
"sourcebucket",
|
"sourcebucket",
|
||||||
@@ -83,9 +85,12 @@ pub const REMOTE_TARGET_WRITABLE_FIELDS: &[&str] = &[
|
|||||||
// madmin default of 60s); the per-target health-check interval is not
|
// madmin default of 60s); the per-target health-check interval is not
|
||||||
// yet applied — the heartbeat keeps its global env-configured interval.
|
// yet applied — the heartbeat keeps its global env-configured interval.
|
||||||
"healthCheckDuration",
|
"healthCheckDuration",
|
||||||
|
// Per-target read-proxy opt-out, consumed by the proxy-target selector
|
||||||
|
// (contract v2; previously only importable via MinIO bucket-targets.json).
|
||||||
|
"disableProxy",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const REMOTE_TARGET_UNSUPPORTED_FIELDS: &[&str] = &["disableProxy", "edge", "edgeSyncBeforeExpiry"];
|
pub const REMOTE_TARGET_UNSUPPORTED_FIELDS: &[&str] = &["edge", "edgeSyncBeforeExpiry"];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ObjectOpts {
|
pub struct ObjectOpts {
|
||||||
|
|||||||
@@ -125,6 +125,34 @@ pub(crate) async fn read_config_with_revision<S: ScannerObjectIO>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read only the object revision without materializing its body.
|
||||||
|
pub(crate) async fn read_config_revision<S: ScannerObjectIO>(store: Arc<S>, path: &str) -> StorageResult<DataUsageCacheRevision> {
|
||||||
|
match store
|
||||||
|
.get_object_reader(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
path,
|
||||||
|
None,
|
||||||
|
HeaderMap::new(),
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(reader) => reader
|
||||||
|
.object_info
|
||||||
|
.etag
|
||||||
|
.filter(|etag| !etag.is_empty())
|
||||||
|
.map(DataUsageCacheRevision::Etag)
|
||||||
|
.ok_or_else(|| StorageError::other(format!("scanner config object {path} has no ETag"))),
|
||||||
|
Err(Error::FileNotFound | Error::VolumeNotFound | Error::ObjectNotFound(_, _) | Error::BucketNotFound(_)) => {
|
||||||
|
Ok(DataUsageCacheRevision::Missing)
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct DataUsageCacheRevisions {
|
pub(crate) struct DataUsageCacheRevisions {
|
||||||
main: DataUsageCacheRevision,
|
main: DataUsageCacheRevision,
|
||||||
@@ -146,6 +174,11 @@ pub static LEGACY_DATA_USAGE_OBJ_NAME_PATH: LazyLock<String> =
|
|||||||
pub static DATA_USAGE_BLOOM_NAME_PATH: LazyLock<String> =
|
pub static DATA_USAGE_BLOOM_NAME_PATH: LazyLock<String> =
|
||||||
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{DATA_USAGE_BLOOM_NAME}"));
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}{DATA_USAGE_BLOOM_NAME}"));
|
||||||
|
|
||||||
|
/// Durable companion object for a cycle-state object which cannot be decoded.
|
||||||
|
/// The primary object is deliberately never replaced or deleted by recovery.
|
||||||
|
pub static DATA_USAGE_BLOOM_RECOVERY_PATH: LazyLock<String> =
|
||||||
|
LazyLock::new(|| format!("{}.recovery-required.json", DATA_USAGE_BLOOM_NAME_PATH.as_str()));
|
||||||
|
|
||||||
pub static BACKGROUND_HEAL_INFO_PATH: LazyLock<String> =
|
pub static BACKGROUND_HEAL_INFO_PATH: LazyLock<String> =
|
||||||
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}.background-heal.json"));
|
LazyLock::new(|| format!("{BUCKET_META_PREFIX}{SLASH_SEPARATOR}.background-heal.json"));
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ impl DataUsageCache {
|
|||||||
let loaded = Self::load_cache(store.clone(), name).await?;
|
let loaded = Self::load_cache(store.clone(), name).await?;
|
||||||
let backup = match loaded.backup_revision {
|
let backup = match loaded.backup_revision {
|
||||||
Some(revision) => Some(revision),
|
Some(revision) => Some(revision),
|
||||||
None => match Self::revision_for_path(store, &backup_path).await {
|
None => match read_config_revision(store, &backup_path).await {
|
||||||
Ok(revision) => Some(revision),
|
Ok(revision) => Some(revision),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
counter!(METRIC_CACHE_BACKUP_REVISION_FAILURE_TOTAL).increment(1);
|
counter!(METRIC_CACHE_BACKUP_REVISION_FAILURE_TOTAL).increment(1);
|
||||||
@@ -336,33 +336,6 @@ impl DataUsageCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn revision_for_path<S: ScannerObjectIO>(store: Arc<S>, path: &str) -> StorageResult<DataUsageCacheRevision> {
|
|
||||||
match store
|
|
||||||
.get_object_reader(
|
|
||||||
RUSTFS_META_BUCKET,
|
|
||||||
path,
|
|
||||||
None,
|
|
||||||
HeaderMap::new(),
|
|
||||||
&ObjectOptions {
|
|
||||||
no_lock: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(reader) => reader
|
|
||||||
.object_info
|
|
||||||
.etag
|
|
||||||
.filter(|etag| !etag.is_empty())
|
|
||||||
.map(DataUsageCacheRevision::Etag)
|
|
||||||
.ok_or_else(|| StorageError::other(format!("scanner cache object {path} has no ETag"))),
|
|
||||||
Err(Error::FileNotFound | Error::VolumeNotFound | Error::ObjectNotFound(_, _) | Error::BucketNotFound(_)) => {
|
|
||||||
Ok(DataUsageCacheRevision::Missing)
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn cache_save_timeout() -> Duration {
|
pub(super) fn cache_save_timeout() -> Duration {
|
||||||
crate::runtime_config::scanner_cache_save_timeout()
|
crate::runtime_config::scanner_cache_save_timeout()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ pub use remote_scanner::{
|
|||||||
};
|
};
|
||||||
pub use runtime_config::{apply_scanner_runtime_config, scanner_runtime_config_status, validate_scanner_runtime_config};
|
pub use runtime_config::{apply_scanner_runtime_config, scanner_runtime_config_status, validate_scanner_runtime_config};
|
||||||
pub use rustfs_common::last_minute;
|
pub use rustfs_common::last_minute;
|
||||||
pub use scanner::{ScannerCycleScheduleStatus, init_data_scanner, scanner_cycle_schedule_status, scanner_topology_digest};
|
pub use scanner::{
|
||||||
|
ScannerCycleRecoveryMarker, ScannerCycleRecoveryStatus, ScannerCycleScheduleStatus, init_data_scanner,
|
||||||
|
reset_scanner_cycle_recovery, scanner_cycle_recovery_status, scanner_cycle_schedule_status, scanner_topology_digest,
|
||||||
|
};
|
||||||
pub use scanner_io::{
|
pub use scanner_io::{
|
||||||
ScannerDirtyUsageAckError, ScannerDirtyUsageState, acknowledge_dirty_usage_generation, clear_dirty_usage_bucket,
|
ScannerDirtyUsageAckError, ScannerDirtyUsageState, acknowledge_dirty_usage_generation, clear_dirty_usage_bucket,
|
||||||
record_dirty_usage_bucket, record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_state,
|
record_dirty_usage_bucket, record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_state,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use std::sync::{Arc, LazyLock, RwLock};
|
|||||||
|
|
||||||
use crate::data_usage_define::{
|
use crate::data_usage_define::{
|
||||||
BACKGROUND_HEAL_INFO_PATH, DATA_USAGE_BLOOM_NAME_PATH, DATA_USAGE_OBJ_NAME_PATH, DATA_USAGE_OBSERVED_OBJ_NAME_PATH,
|
BACKGROUND_HEAL_INFO_PATH, DATA_USAGE_BLOOM_NAME_PATH, DATA_USAGE_OBJ_NAME_PATH, DATA_USAGE_OBSERVED_OBJ_NAME_PATH,
|
||||||
DataUsageCache, DataUsageCacheRevision, LEGACY_DATA_USAGE_OBJ_NAME_PATH, read_config_with_revision,
|
DataUsageCache, DataUsageCacheRevision, LEGACY_DATA_USAGE_OBJ_NAME_PATH, read_config_revision, read_config_with_revision,
|
||||||
};
|
};
|
||||||
use crate::runtime_config::{
|
use crate::runtime_config::{
|
||||||
ScannerRuntimeConfig, ScannerRuntimeConfigSource, refresh_scanner_runtime_config_from_global, scanner_bitrot_cycle,
|
ScannerRuntimeConfig, ScannerRuntimeConfigSource, refresh_scanner_runtime_config_from_global, scanner_bitrot_cycle,
|
||||||
@@ -54,9 +54,7 @@ use rustfs_config::{ENV_SCANNER_CYCLE, ENV_SCANNER_SPEED, ENV_SCANNER_START_DELA
|
|||||||
use rustfs_data_usage::observed_data_usage_is_newer;
|
use rustfs_data_usage::observed_data_usage_is_newer;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
#[cfg(test)]
|
use tokio::sync::{Notify, mpsc};
|
||||||
use tokio::sync::Notify;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::time::{Duration, Instant};
|
use tokio::time::{Duration, Instant};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::AbortOnDropHandle;
|
use tokio_util::task::AbortOnDropHandle;
|
||||||
@@ -104,6 +102,13 @@ const CLEAN_IDLE_BACKOFF_FACTOR: u32 = 2;
|
|||||||
/// unavailable peer cannot drive a tight retry loop.
|
/// unavailable peer cannot drive a tight retry loop.
|
||||||
const SCANNER_RETRY_BASE_INTERVAL: Duration = Duration::from_secs(5);
|
const SCANNER_RETRY_BASE_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
const SCANNER_RETRY_MAX_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
const SCANNER_RETRY_MAX_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
||||||
|
/// A transient backend outage remains self-healing after the short retry
|
||||||
|
/// budget is exhausted, but the probe is intentionally sparse until storage
|
||||||
|
/// recovers or an operator reset wakes the scanner.
|
||||||
|
const SCANNER_CYCLE_RECOVERY_PAUSED_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||||
|
/// Permanent recovery states still get a sparse status probe so a reset that
|
||||||
|
/// races the wait registration cannot leave the scanner asleep forever.
|
||||||
|
const SCANNER_CYCLE_RECOVERY_BLOCKED_PROBE_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||||
const SCANNER_LEADER_LOCK_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
const SCANNER_LEADER_LOCK_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
const SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
|
const SCANNER_LOCK_LOSS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
@@ -125,6 +130,12 @@ type ScannerCycleStatePersistTestHook = (u64, Arc<Notify>);
|
|||||||
static SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK: LazyLock<StdMutex<Option<ScannerCycleStatePersistTestHook>>> =
|
static SCANNER_CYCLE_STATE_PERSIST_TEST_HOOK: LazyLock<StdMutex<Option<ScannerCycleStatePersistTestHook>>> =
|
||||||
LazyLock::new(|| StdMutex::new(None));
|
LazyLock::new(|| StdMutex::new(None));
|
||||||
|
|
||||||
|
static SCANNER_CYCLE_RECOVERY_WAKE: LazyLock<Notify> = LazyLock::new(Notify::new);
|
||||||
|
|
||||||
|
pub(super) fn notify_scanner_cycle_recovery_wake() {
|
||||||
|
SCANNER_CYCLE_RECOVERY_WAKE.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
struct ScannerCycleStatePersistTestHookGuard;
|
struct ScannerCycleStatePersistTestHookGuard;
|
||||||
|
|
||||||
@@ -576,19 +587,21 @@ pub async fn init_data_scanner(ctx: CancellationToken, storeapi: Arc<ECStore>) {
|
|||||||
tokio::time::sleep(sleep_time).await;
|
tokio::time::sleep(sleep_time).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut transient_backoff = ScannerRetryBackoff::default();
|
||||||
|
let mut recovery_retry_count = 0_u32;
|
||||||
loop {
|
loop {
|
||||||
if ctx_clone.is_cancelled() {
|
if ctx_clone.is_cancelled() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = run_data_scanner_with_maintenance_state(
|
let run_result = run_data_scanner_with_maintenance_state(
|
||||||
ctx_clone.clone(),
|
ctx_clone.clone(),
|
||||||
storeapi_clone.clone(),
|
storeapi_clone.clone(),
|
||||||
startup_features,
|
startup_features,
|
||||||
startup_maintenance_generation,
|
startup_maintenance_generation,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
{
|
if let Err(e) = &run_result {
|
||||||
error!(
|
error!(
|
||||||
target: "rustfs::scanner",
|
target: "rustfs::scanner",
|
||||||
event = EVENT_SCANNER_CYCLE_STATE,
|
event = EVENT_SCANNER_CYCLE_STATE,
|
||||||
@@ -599,11 +612,52 @@ pub async fn init_data_scanner(ctx: CancellationToken, storeapi: Arc<ECStore>) {
|
|||||||
"Scanner runtime iteration failed"
|
"Scanner runtime iteration failed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let recovery_status = scanner_cycle_recovery_status();
|
||||||
|
if recovery_status.retryable {
|
||||||
|
recovery_retry_count = recovery_retry_count.saturating_add(1);
|
||||||
|
let _ = record_scanner_cycle_recovery_retry(recovery_retry_count);
|
||||||
|
} else {
|
||||||
|
recovery_retry_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recovery_status = scanner_cycle_recovery_status();
|
||||||
|
if recovery_status.state == "paused" {
|
||||||
|
transient_backoff.record_retryable_cycle(false);
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctx_clone.cancelled() => break,
|
||||||
|
_ = SCANNER_CYCLE_RECOVERY_WAKE.notified() => {},
|
||||||
|
_ = tokio::time::sleep(SCANNER_CYCLE_RECOVERY_PAUSED_INTERVAL) => {},
|
||||||
|
}
|
||||||
|
recovery_retry_count = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !recovery_status.retryable
|
||||||
|
&& matches!(recovery_status.state.as_str(), "blocked" | "recovery-required" | "cleanup-pending")
|
||||||
|
{
|
||||||
|
transient_backoff.record_retryable_cycle(false);
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctx_clone.cancelled() => break,
|
||||||
|
_ = SCANNER_CYCLE_RECOVERY_WAKE.notified() => {},
|
||||||
|
_ = tokio::time::sleep(SCANNER_CYCLE_RECOVERY_BLOCKED_PROBE_INTERVAL) => {},
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let retry_delay = if recovery_status.retryable || run_result.is_err() {
|
||||||
|
transient_backoff.record_retryable_cycle(true);
|
||||||
|
transient_backoff
|
||||||
|
.retry_interval(scanner_cycle_interval())
|
||||||
|
.unwrap_or(SCANNER_RETRY_BASE_INTERVAL)
|
||||||
|
} else {
|
||||||
|
transient_backoff.record_retryable_cycle(false);
|
||||||
|
randomized_cycle_delay()
|
||||||
|
};
|
||||||
// Backoff before retrying after lock contention or scanner-level failures.
|
// Backoff before retrying after lock contention or scanner-level failures.
|
||||||
// Keep this cancellation-aware so shutdown is not delayed by backoff sleep.
|
// Keep this cancellation-aware so shutdown is not delayed by backoff sleep.
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = ctx_clone.cancelled() => break,
|
_ = ctx_clone.cancelled() => break,
|
||||||
_ = tokio::time::sleep(randomized_cycle_delay()) => {}
|
_ = SCANNER_CYCLE_RECOVERY_WAKE.notified() => {},
|
||||||
|
_ = tokio::time::sleep(retry_delay) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1606,40 +1660,22 @@ async fn run_data_scanner_with_maintenance_state(
|
|||||||
observe_scanner_activity(&storeapi, distributed, &mut scanner_activity_seen).await;
|
observe_scanner_activity(&storeapi, distributed, &mut scanner_activity_seen).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (buf, mut cycle_revision) = match read_config_with_revision(storeapi.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str()).await {
|
let (mut cycle_info, mut leader_epoch, mut cycle_revision) =
|
||||||
Ok((buf, revision)) => (buf.unwrap_or_default(), revision),
|
match load_scanner_cycle_state_for_startup(storeapi.clone()).await {
|
||||||
Err(err) => {
|
ScannerCycleStateStartup::Ready {
|
||||||
error!(
|
cycle,
|
||||||
target: "rustfs::scanner",
|
leader_epoch,
|
||||||
event = EVENT_SCANNER_PERSIST_STATE,
|
revision,
|
||||||
component = LOG_COMPONENT_SCANNER,
|
} => (cycle, leader_epoch, revision),
|
||||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
ScannerCycleStateStartup::Blocked => {
|
||||||
path = %&*DATA_USAGE_BLOOM_NAME_PATH,
|
global_metrics().set_cycle(None).await;
|
||||||
state = "revision_load_failed",
|
return Ok(());
|
||||||
error = %err,
|
}
|
||||||
"Scanner cycle state revision load failed"
|
ScannerCycleStateStartup::Transient(err) => {
|
||||||
);
|
global_metrics().set_cycle(None).await;
|
||||||
global_metrics().set_cycle(None).await;
|
return Err(err);
|
||||||
return Ok(());
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
let (mut cycle_info, mut leader_epoch) = match decode_scanner_cycle_state_for_startup(&buf) {
|
|
||||||
Ok(state) => state,
|
|
||||||
Err(err) => {
|
|
||||||
error!(
|
|
||||||
target: "rustfs::scanner",
|
|
||||||
event = EVENT_SCANNER_PERSIST_STATE,
|
|
||||||
component = LOG_COMPONENT_SCANNER,
|
|
||||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
|
||||||
path = %&*DATA_USAGE_BLOOM_NAME_PATH,
|
|
||||||
state = "cycle_decode_failed",
|
|
||||||
error = %err,
|
|
||||||
"Scanner stopped because persisted cycle state is invalid"
|
|
||||||
);
|
|
||||||
global_metrics().set_cycle(None).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let usage_floor = match persisted_usage_floor(storeapi.clone()).await {
|
let usage_floor = match persisted_usage_floor(storeapi.clone()).await {
|
||||||
Ok(floor) => floor,
|
Ok(floor) => floor,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -2219,7 +2255,12 @@ pub(crate) use activity::{
|
|||||||
pub(crate) use activity::{ScannerCycleOutcome, scanner_cycle_outcome_with_pending_maintenance};
|
pub(crate) use activity::{ScannerCycleOutcome, scanner_cycle_outcome_with_pending_maintenance};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use cycle_state::encode_scanner_cycle_fence_for_test;
|
pub(crate) use cycle_state::encode_scanner_cycle_fence_for_test;
|
||||||
pub(crate) use cycle_state::{current_scanner_leader_epoch, decode_persisted_scanner_cycle_fence};
|
pub use cycle_state::{
|
||||||
|
ScannerCycleRecoveryMarker, ScannerCycleRecoveryStatus, reset_scanner_cycle_recovery, scanner_cycle_recovery_status,
|
||||||
|
};
|
||||||
|
pub(crate) use cycle_state::{
|
||||||
|
current_scanner_leader_epoch, decode_persisted_scanner_cycle_fence, load_scanner_cycle_state_for_startup,
|
||||||
|
};
|
||||||
pub use heal_info::{BackgroundHealInfo, read_background_heal_info, save_background_heal_info};
|
pub use heal_info::{BackgroundHealInfo, read_background_heal_info, save_background_heal_info};
|
||||||
pub use usage_store::store_data_usage_in_backend;
|
pub use usage_store::store_data_usage_in_backend;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -196,7 +196,7 @@ pub(super) async fn claim_scanner_leadership(
|
|||||||
if ctx.is_cancelled() {
|
if ctx.is_cancelled() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(claimed_epoch) = persisted_epoch.checked_add(1) else {
|
let Some(claimed_epoch) = persisted_epoch.checked_add(1).filter(|epoch| *epoch < u64::MAX) else {
|
||||||
error!(
|
error!(
|
||||||
target: "rustfs::scanner",
|
target: "rustfs::scanner",
|
||||||
event = EVENT_SCANNER_PERSIST_STATE,
|
event = EVENT_SCANNER_PERSIST_STATE,
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::EcstoreResult;
|
use crate::EcstoreResult;
|
||||||
use crate::{
|
use crate::{
|
||||||
Endpoint, EndpointServerPools, Endpoints, InstanceContext, PoolEndpoints, ScannerGetObjectReader as GetObjectReader,
|
DATA_USAGE_BLOOM_RECOVERY_PATH, Endpoint, EndpointServerPools, Endpoints, InstanceContext, PoolEndpoints,
|
||||||
ScannerObjectInfo as ObjectInfo, ScannerObjectOptions as ObjectOptions, ScannerPutObjReader as PutObjReader,
|
ScannerGetObjectReader as GetObjectReader, ScannerObjectInfo as ObjectInfo, ScannerObjectOptions as ObjectOptions,
|
||||||
init_bucket_metadata_sys_for_scanner_tests, init_ecstore_config_for_scanner_tests, init_local_disks_with_instance_ctx,
|
ScannerPutObjReader as PutObjReader, init_bucket_metadata_sys_for_scanner_tests, init_ecstore_config_for_scanner_tests,
|
||||||
|
init_local_disks_with_instance_ctx,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::task::Poll;
|
use std::task::Poll;
|
||||||
use temp_env::{with_var, with_var_unset};
|
use temp_env::{with_var, with_var_unset};
|
||||||
@@ -117,6 +118,15 @@ async fn scanner_cycle_lock_fence_bounds_uncooperative_shutdown() {
|
|||||||
assert!(cycle_ctx.is_cancelled());
|
assert!(cycle_ctx.is_cancelled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_cycle_recovery_wake_survives_wait_registration_race() {
|
||||||
|
notify_scanner_cycle_recovery_wake();
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), SCANNER_CYCLE_RECOVERY_WAKE.notified())
|
||||||
|
.await
|
||||||
|
.expect("recovery wake should retain a permit until the waiter registers");
|
||||||
|
}
|
||||||
|
|
||||||
struct ScannerDefaultSpeedGuard;
|
struct ScannerDefaultSpeedGuard;
|
||||||
|
|
||||||
impl ScannerDefaultSpeedGuard {
|
impl ScannerDefaultSpeedGuard {
|
||||||
@@ -151,6 +161,7 @@ impl Drop for ScannerDefaultCycleGuard {
|
|||||||
struct MemoryConfigStore {
|
struct MemoryConfigStore {
|
||||||
objects: Mutex<HashMap<String, Vec<u8>>>,
|
objects: Mutex<HashMap<String, Vec<u8>>>,
|
||||||
revisions: Mutex<HashMap<String, u64>>,
|
revisions: Mutex<HashMap<String, u64>>,
|
||||||
|
non_regular_objects: Mutex<HashSet<String>>,
|
||||||
fail_put_number: Mutex<HashMap<String, usize>>,
|
fail_put_number: Mutex<HashMap<String, usize>>,
|
||||||
object_not_found_put_number: Mutex<HashMap<String, usize>>,
|
object_not_found_put_number: Mutex<HashMap<String, usize>>,
|
||||||
error_after_commit_put_number: Mutex<HashMap<String, usize>>,
|
error_after_commit_put_number: Mutex<HashMap<String, usize>>,
|
||||||
@@ -191,12 +202,16 @@ impl crate::storage_api::scanner_io::ObjectIO for MemoryConfigStore {
|
|||||||
.get(&key)
|
.get(&key)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(EcstoreError::FileNotFound)?;
|
.ok_or(EcstoreError::FileNotFound)?;
|
||||||
let revision = *self.revisions.lock().await.entry(key).or_insert(1);
|
let data_len = i64::try_from(data.len()).expect("memory test object length should fit in i64");
|
||||||
|
let revision = *self.revisions.lock().await.entry(key.clone()).or_insert(1);
|
||||||
|
let is_dir = self.non_regular_objects.lock().await.contains(&key);
|
||||||
|
|
||||||
Ok(GetObjectReader {
|
Ok(GetObjectReader {
|
||||||
stream: Box::new(Cursor::new(data)),
|
stream: Box::new(Cursor::new(data)),
|
||||||
object_info: ObjectInfo {
|
object_info: ObjectInfo {
|
||||||
etag: Some(format!("memory-{revision}")),
|
etag: Some(format!("memory-{revision}")),
|
||||||
|
size: data_len,
|
||||||
|
is_dir,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
buffered_body: None,
|
buffered_body: None,
|
||||||
@@ -797,6 +812,10 @@ fn scanner_cycle_state_decodes_legacy_and_fenced_formats() {
|
|||||||
let (fenced_cycle, fenced_epoch) = decode_scanner_cycle_state(&fenced).expect("fenced cycle state should decode");
|
let (fenced_cycle, fenced_epoch) = decode_scanner_cycle_state(&fenced).expect("fenced cycle state should decode");
|
||||||
assert_eq!(fenced_cycle.next, 13);
|
assert_eq!(fenced_cycle.next, 13);
|
||||||
assert_eq!(fenced_epoch, 7);
|
assert_eq!(fenced_epoch, 7);
|
||||||
|
|
||||||
|
let mut trailing = fenced;
|
||||||
|
trailing.push(0);
|
||||||
|
assert!(decode_scanner_cycle_state(&trailing).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -823,6 +842,840 @@ fn scanner_startup_fails_closed_on_nonempty_corrupt_cycle_state() {
|
|||||||
assert!(encode_scanner_cycle_state(&exhausted, 7).is_err());
|
assert!(encode_scanner_cycle_state(&exhausted, 7).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn corrupt_cycle_state_is_quarantined_once() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let state_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), vec![1]);
|
||||||
|
store.revisions.lock().await.insert(state_key.clone(), 7);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store.clone()).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
let marker_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str());
|
||||||
|
let marker_data = store
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&marker_key)
|
||||||
|
.cloned()
|
||||||
|
.expect("corrupt state must leave a durable recovery marker");
|
||||||
|
let marker: ScannerCycleRecoveryMarker = serde_json::from_slice(&marker_data).expect("marker should be valid JSON");
|
||||||
|
assert_eq!(marker.primary_revision, "memory-7");
|
||||||
|
assert_eq!(marker.path, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
assert_eq!(marker.quarantine_path, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str());
|
||||||
|
assert_eq!(marker.classification, "corrupt");
|
||||||
|
|
||||||
|
// A second startup sees the matching marker before consuming the poison body.
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store.clone()).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
|
||||||
|
// Replacing the primary object advances its revision; the stale marker must
|
||||||
|
// not quarantine the newer, valid state.
|
||||||
|
let cycle = CurrentCycle {
|
||||||
|
next: 9,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let encoded = encode_scanner_cycle_state(&cycle, 3).expect("valid state should encode");
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), encoded);
|
||||||
|
store.revisions.lock().await.insert(state_key, 8);
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store).await,
|
||||||
|
ScannerCycleStateStartup::Ready {
|
||||||
|
cycle: CurrentCycle { next: 9, .. },
|
||||||
|
leader_epoch: 3,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_cycle_state_object_is_quarantined_as_corrupt() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let state_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), Vec::new());
|
||||||
|
store.revisions.lock().await.insert(state_key, 6);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
assert_eq!(scanner_cycle_recovery_status().classification.as_deref(), Some("corrupt"));
|
||||||
|
assert!(
|
||||||
|
scanner_cycle_recovery_status()
|
||||||
|
.reason
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|reason| reason.contains("empty"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn future_cycle_state_schema_is_recovery_required() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let state_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
let mut future = 17_u64.to_le_bytes().to_vec();
|
||||||
|
future.extend_from_slice(b"RSCYC999");
|
||||||
|
future.extend_from_slice(&4_u64.to_le_bytes());
|
||||||
|
future.extend_from_slice(&[0x90]);
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), future);
|
||||||
|
store.revisions.lock().await.insert(state_key, 13);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
assert_eq!(scanner_cycle_recovery_status().classification.as_deref(), Some("future_schema"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_leaders_cannot_quarantine_newer_cycle_state() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let state_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), vec![1]);
|
||||||
|
store.revisions.lock().await.insert(state_key, 4);
|
||||||
|
|
||||||
|
let (first, second) = tokio::join!(
|
||||||
|
load_scanner_cycle_state_for_startup(store.clone()),
|
||||||
|
load_scanner_cycle_state_for_startup(store.clone()),
|
||||||
|
);
|
||||||
|
assert!(matches!(first, ScannerCycleStateStartup::Blocked));
|
||||||
|
assert!(matches!(second, ScannerCycleStateStartup::Blocked));
|
||||||
|
|
||||||
|
let marker_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str());
|
||||||
|
let marker_data = store
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&marker_key)
|
||||||
|
.cloned()
|
||||||
|
.expect("one contender must publish the recovery marker");
|
||||||
|
let marker: ScannerCycleRecoveryMarker = serde_json::from_slice(&marker_data).expect("marker should decode");
|
||||||
|
assert_eq!(marker.primary_revision, "memory-4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cleanup_pending_marker_blocks_a_rewritten_primary_after_restart() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let state_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
let marker_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str());
|
||||||
|
let encoded = encode_scanner_cycle_state(
|
||||||
|
&CurrentCycle {
|
||||||
|
next: 12,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
.expect("valid state should encode");
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), encoded);
|
||||||
|
store.revisions.lock().await.insert(state_key, 22);
|
||||||
|
let marker = ScannerCycleRecoveryMarker {
|
||||||
|
schema_version: 1,
|
||||||
|
primary_revision: "memory-21".to_string(),
|
||||||
|
generation: 11,
|
||||||
|
leader_epoch: 7,
|
||||||
|
classification: "corrupt".to_string(),
|
||||||
|
first_detected_at_unix_secs: 1,
|
||||||
|
last_attempt_at_unix_secs: 2,
|
||||||
|
retry_count: 1,
|
||||||
|
reason: "reset in progress".to_string(),
|
||||||
|
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||||
|
quarantine_path: DATA_USAGE_BLOOM_RECOVERY_PATH.clone(),
|
||||||
|
state: "cleanup-pending".to_string(),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(marker_key.clone(), serde_json::to_vec(&marker).expect("marker should encode"));
|
||||||
|
store.revisions.lock().await.insert(marker_key, 3);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
assert_eq!(scanner_cycle_recovery_status().state, "cleanup-pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_rescan_reset_accepts_unknown_marker_fields_without_trusting_cursor() {
|
||||||
|
let marker = br#"{
|
||||||
|
"schema_version": 99,
|
||||||
|
"primary_revision": "memory-7",
|
||||||
|
"generation": 9000,
|
||||||
|
"leader_epoch": 9000,
|
||||||
|
"classification": "new-future-classification",
|
||||||
|
"first_detected_at_unix_secs": 1,
|
||||||
|
"last_attempt_at_unix_secs": 2,
|
||||||
|
"retry_count": 9,
|
||||||
|
"reason": "future marker",
|
||||||
|
"path": "buckets/.bloomcycle.bin",
|
||||||
|
"quarantine_path": "buckets/.bloomcycle.bin.recovery-required.json",
|
||||||
|
"future_field": {"cursor": "untrusted"}
|
||||||
|
}"#;
|
||||||
|
let decoded =
|
||||||
|
super::cycle_state::decode_recovery_marker_for_reset(marker, &DataUsageCacheRevision::Etag("memory-3".to_string()))
|
||||||
|
.expect("full-rescan compatibility decoder should accept additive fields");
|
||||||
|
assert_eq!(decoded.primary_revision, "memory-7");
|
||||||
|
assert_eq!(decoded.classification, "future_schema");
|
||||||
|
assert_eq!(decoded.generation, 0);
|
||||||
|
assert_eq!(decoded.leader_epoch, 0);
|
||||||
|
assert_eq!(decoded.state, "blocked");
|
||||||
|
|
||||||
|
let malformed =
|
||||||
|
super::cycle_state::decode_recovery_marker_for_reset(b"{not-json", &DataUsageCacheRevision::Etag("memory-4".to_string()))
|
||||||
|
.expect("a full-rescan reset must recover even when the marker is malformed");
|
||||||
|
assert!(malformed.primary_revision.is_empty());
|
||||||
|
assert_eq!(malformed.classification, "future_schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_after_malformed_marker_without_trusting_cursor() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), vec![0xff, 0x00, 0x01])
|
||||||
|
.await
|
||||||
|
.expect("corrupt cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), br#"{not-json"#.to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("full-rescan reset should recover malformed marker");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(cycle.next, 0, "reset must use the verified usage floor, not marker cursor");
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_ignores_epoch_from_malformed_future_primary() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let mut future_primary = vec![0; 24];
|
||||||
|
future_primary[8..16].copy_from_slice(b"RSCY9999");
|
||||||
|
future_primary[16..24].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), future_primary)
|
||||||
|
.await
|
||||||
|
.expect("future cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), br#"{not-json"#.to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("full-rescan reset should recover malformed future state");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (_, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(leader_epoch, 1, "invalid persisted bytes must not raise the recovery epoch");
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ecstore_exact_recovery_marker_delete_honors_etag() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"marker-v1".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("initial recovery marker should be persisted");
|
||||||
|
let (_, stale_revision) = read_config_with_revision(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("initial marker revision should load");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"marker-v2".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("replacement recovery marker should be persisted");
|
||||||
|
|
||||||
|
let delete_result = store
|
||||||
|
.delete_config_object(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||||
|
ObjectOptions {
|
||||||
|
http_preconditions: Some(stale_revision.preconditions()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(delete_result, Err(EcstoreError::PreconditionFailed)));
|
||||||
|
assert_eq!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("replacement marker should remain durable"),
|
||||||
|
b"marker-v2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rejects_corrupt_primary_under_stale_blocked_marker() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let corrupt_primary = vec![0xff, 0x00, 0x01];
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), corrupt_primary.clone())
|
||||||
|
.await
|
||||||
|
.expect("corrupt cycle state should be persisted");
|
||||||
|
let (_, primary_revision) = read_config_with_revision(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("primary revision should load");
|
||||||
|
let marker = ScannerCycleRecoveryMarker {
|
||||||
|
schema_version: 1,
|
||||||
|
primary_revision: "memory-stale".to_string(),
|
||||||
|
generation: 1,
|
||||||
|
leader_epoch: 1,
|
||||||
|
classification: "corrupt".to_string(),
|
||||||
|
first_detected_at_unix_secs: 1,
|
||||||
|
last_attempt_at_unix_secs: 2,
|
||||||
|
retry_count: 1,
|
||||||
|
reason: "blocked primary changed".to_string(),
|
||||||
|
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||||
|
quarantine_path: DATA_USAGE_BLOOM_RECOVERY_PATH.clone(),
|
||||||
|
state: "blocked".to_string(),
|
||||||
|
};
|
||||||
|
let marker_data = serde_json::to_vec(&marker).expect("blocked marker should encode");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), marker_data.clone())
|
||||||
|
.await
|
||||||
|
.expect("blocked marker should be persisted");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"a strict marker must fail closed when its primary revision changed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("primary should remain readable"),
|
||||||
|
corrupt_primary
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("blocked marker should remain durable"),
|
||||||
|
marker_data
|
||||||
|
);
|
||||||
|
assert!(!matches!(primary_revision, DataUsageCacheRevision::Missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_preserves_valid_primary_when_marker_is_malformed() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let primary = CurrentCycle {
|
||||||
|
next: 42,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let old_primary_data = encode_scanner_cycle_state(&primary, 7).expect("valid cycle state should encode");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), old_primary_data.clone())
|
||||||
|
.await
|
||||||
|
.expect("valid cycle state should be persisted");
|
||||||
|
let (_, old_primary_revision) = read_config_with_revision(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("primary state revision should load");
|
||||||
|
let old_usage = DataUsageInfo {
|
||||||
|
scanner_epoch: Some(7),
|
||||||
|
scanner_cycle: Some(41),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let old_usage_data = serde_json::to_vec(&old_usage).expect("usage snapshot should encode");
|
||||||
|
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), old_usage_data.clone())
|
||||||
|
.await
|
||||||
|
.expect("usage snapshot should be persisted");
|
||||||
|
let (_, old_usage_revision) = read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("usage snapshot revision should load");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"{not-json".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("reset should clear a stale malformed marker");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("valid primary should remain durable");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("primary cycle state should decode");
|
||||||
|
assert_eq!(cycle.next, 42, "reset must not regress an independently fenced primary");
|
||||||
|
assert_eq!(leader_epoch, 8, "reset must advance the preserved primary epoch");
|
||||||
|
let stale_primary_save = save_config_with_preconditions(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||||
|
old_primary_data,
|
||||||
|
old_primary_revision.preconditions(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(stale_primary_save, Err(EcstoreError::PreconditionFailed)));
|
||||||
|
let usage = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("usage epoch fence should remain durable");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<DataUsageInfo>(&usage)
|
||||||
|
.expect("fenced usage should decode")
|
||||||
|
.scanner_epoch,
|
||||||
|
Some(8)
|
||||||
|
);
|
||||||
|
let stale_save = save_config_with_preconditions(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||||
|
old_usage_data,
|
||||||
|
old_usage_revision.preconditions(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(stale_save, Err(EcstoreError::PreconditionFailed)));
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_resumes_cleanup_pending_preserved_primary() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let completed_at = Utc::now();
|
||||||
|
let primary = CurrentCycle {
|
||||||
|
current: 3,
|
||||||
|
next: 42,
|
||||||
|
cycle_completed: vec![completed_at],
|
||||||
|
started: completed_at,
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||||
|
encode_scanner_cycle_state(&primary, 7).expect("valid cycle state should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("valid cycle state should be persisted");
|
||||||
|
let usage = DataUsageInfo {
|
||||||
|
scanner_epoch: Some(7),
|
||||||
|
scanner_cycle: Some(41),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||||
|
serde_json::to_vec(&usage).expect("usage snapshot should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("usage snapshot should be persisted");
|
||||||
|
let marker = ScannerCycleRecoveryMarker {
|
||||||
|
schema_version: 1,
|
||||||
|
primary_revision: "memory-old".to_string(),
|
||||||
|
generation: 41,
|
||||||
|
leader_epoch: 7,
|
||||||
|
classification: "corrupt".to_string(),
|
||||||
|
first_detected_at_unix_secs: 1,
|
||||||
|
last_attempt_at_unix_secs: 2,
|
||||||
|
retry_count: 1,
|
||||||
|
reason: "reset in progress".to_string(),
|
||||||
|
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||||
|
quarantine_path: DATA_USAGE_BLOOM_RECOVERY_PATH.clone(),
|
||||||
|
state: "cleanup-pending".to_string(),
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||||
|
serde_json::to_vec(&marker).expect("marker should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("cleanup marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("reset should resume a cleanup-pending preserved primary");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("preserved cycle state should remain durable");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("cycle state should decode");
|
||||||
|
assert_eq!(cycle.current, 3, "cleanup retry must preserve the in-progress cursor");
|
||||||
|
assert_eq!(cycle.next, 42);
|
||||||
|
assert_eq!(cycle.cycle_completed, vec![completed_at]);
|
||||||
|
assert_eq!(cycle.started, completed_at);
|
||||||
|
assert_eq!(leader_epoch, 8);
|
||||||
|
let usage = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("usage epoch fence should remain durable");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<DataUsageInfo>(&usage)
|
||||||
|
.expect("usage should decode")
|
||||||
|
.scanner_epoch,
|
||||||
|
Some(8)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_oversized_regular_primary_with_malformed_marker() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), vec![0; 1024 * 1024 + 1])
|
||||||
|
.await
|
||||||
|
.expect("oversized cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"{not-json".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("explicit full-rescan reset should replace an oversized regular primary");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(cycle.next, 0);
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_oversized_primary_after_cleanup_marker() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), vec![0; 1024 * 1024 + 1])
|
||||||
|
.await
|
||||||
|
.expect("oversized cycle state should be persisted");
|
||||||
|
let (_, primary_revision) = read_config_with_revision(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("primary revision should load");
|
||||||
|
let marker = ScannerCycleRecoveryMarker {
|
||||||
|
schema_version: 1,
|
||||||
|
primary_revision: match primary_revision {
|
||||||
|
DataUsageCacheRevision::Etag(etag) => etag,
|
||||||
|
DataUsageCacheRevision::Missing => panic!("primary revision should be present"),
|
||||||
|
},
|
||||||
|
generation: 1,
|
||||||
|
leader_epoch: 1,
|
||||||
|
classification: "corrupt".to_string(),
|
||||||
|
first_detected_at_unix_secs: 1,
|
||||||
|
last_attempt_at_unix_secs: 2,
|
||||||
|
retry_count: 1,
|
||||||
|
reason: "reset in progress".to_string(),
|
||||||
|
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||||
|
quarantine_path: DATA_USAGE_BLOOM_RECOVERY_PATH.clone(),
|
||||||
|
state: "cleanup-pending".to_string(),
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||||
|
serde_json::to_vec(&marker).expect("cleanup marker should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("cleanup marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("cleanup retry should rebuild an oversized primary");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(cycle.next, 0);
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_with_oversized_marker() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), vec![0xff, 0x00, 0x01])
|
||||||
|
.await
|
||||||
|
.expect("corrupt cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), vec![b'x'; 64 * 1024 + 1])
|
||||||
|
.await
|
||||||
|
.expect("oversized recovery marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("full-rescan reset should recover an oversized marker");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (_, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_with_empty_marker() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), vec![0xff, 0x00, 0x01])
|
||||||
|
.await
|
||||||
|
.expect("corrupt cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("empty recovery marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("full-rescan reset should recover an empty marker");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (_, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_keeps_cleanup_marker_when_preserved_epoch_is_exhausted() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let primary = CurrentCycle {
|
||||||
|
next: 42,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||||
|
encode_scanner_cycle_state(&primary, u64::MAX).expect("valid cycle state should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("valid cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"{not-json".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
let marker = read_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("cleanup marker should remain durable");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<ScannerCycleRecoveryMarker>(&marker)
|
||||||
|
.expect("cleanup marker should decode")
|
||||||
|
.state,
|
||||||
|
"cleanup-pending"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rejects_preserved_epoch_that_would_be_terminal() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let primary = CurrentCycle {
|
||||||
|
next: 42,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_NAME_PATH.as_str(),
|
||||||
|
encode_scanner_cycle_state(&primary, u64::MAX - 1).expect("valid cycle state should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("valid cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"{not-json".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"reset must not persist the terminal leader epoch"
|
||||||
|
);
|
||||||
|
|
||||||
|
let marker = read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("cleanup marker should remain durable");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<ScannerCycleRecoveryMarker>(&marker)
|
||||||
|
.expect("cleanup marker should decode")
|
||||||
|
.state,
|
||||||
|
"cleanup-pending"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rejects_usage_floor_that_would_be_terminal() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), vec![0xff, 0x00, 0x01])
|
||||||
|
.await
|
||||||
|
.expect("corrupt cycle state should be persisted");
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||||
|
serde_json::to_vec(&DataUsageInfo {
|
||||||
|
scanner_epoch: Some(u64::MAX - 1),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.expect("usage floor should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("usage floor should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"{not-json".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"reset must not persist the terminal leader epoch"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("recovery marker should remain durable"),
|
||||||
|
b"{not-json"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_empty_primary_with_malformed_marker() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str(), Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("empty cycle state should be persisted");
|
||||||
|
save_config(store.clone(), DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(), b"{not-json".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("malformed marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("explicit full-rescan reset should replace an empty primary");
|
||||||
|
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("rebuilt cycle state should remain durable");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(cycle.next, 0);
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_rescan_reset_rebuilds_when_primary_cycle_state_is_missing() {
|
||||||
|
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||||
|
let marker = ScannerCycleRecoveryMarker {
|
||||||
|
schema_version: 1,
|
||||||
|
primary_revision: "memory-missing".to_string(),
|
||||||
|
generation: u64::MAX,
|
||||||
|
leader_epoch: u64::MAX,
|
||||||
|
classification: "corrupt".to_string(),
|
||||||
|
first_detected_at_unix_secs: 1,
|
||||||
|
last_attempt_at_unix_secs: 2,
|
||||||
|
retry_count: 0,
|
||||||
|
reason: "missing primary".to_string(),
|
||||||
|
path: DATA_USAGE_BLOOM_NAME_PATH.clone(),
|
||||||
|
quarantine_path: DATA_USAGE_BLOOM_RECOVERY_PATH.clone(),
|
||||||
|
state: "blocked".to_string(),
|
||||||
|
};
|
||||||
|
save_config(
|
||||||
|
store.clone(),
|
||||||
|
DATA_USAGE_BLOOM_RECOVERY_PATH.as_str(),
|
||||||
|
serde_json::to_vec(&marker).expect("marker should encode"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("marker should be persisted");
|
||||||
|
|
||||||
|
reset_scanner_cycle_recovery(CancellationToken::new(), store.clone())
|
||||||
|
.await
|
||||||
|
.expect("full-rescan reset should recreate missing primary");
|
||||||
|
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
|
||||||
|
.await
|
||||||
|
.expect("missing primary should be rebuilt");
|
||||||
|
let (cycle, leader_epoch) = decode_scanner_cycle_state(&state).expect("rebuilt cycle state should decode");
|
||||||
|
assert_eq!(cycle.next, 0);
|
||||||
|
assert_eq!(leader_epoch, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
read_config(store, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()).await,
|
||||||
|
Err(EcstoreError::ConfigNotFound)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn corrupt_cycle_state_rename_or_marker_failure_stays_recovery_required() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let state_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
let marker_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str());
|
||||||
|
store.objects.lock().await.insert(state_key.clone(), vec![1]);
|
||||||
|
store.revisions.lock().await.insert(state_key, 9);
|
||||||
|
store.fail_put_number.lock().await.insert(marker_key, 1);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store.clone()).await,
|
||||||
|
ScannerCycleStateStartup::Transient(_)
|
||||||
|
));
|
||||||
|
let status = scanner_cycle_recovery_status();
|
||||||
|
assert_eq!(status.state, "recovery-required");
|
||||||
|
assert!(status.retryable);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.contains_key(&memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_or_symlinked_cycle_state_is_rejected() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_NAME_PATH.as_str());
|
||||||
|
store.objects.lock().await.insert(key.clone(), vec![0; 1024 * 1024 + 1]);
|
||||||
|
store.revisions.lock().await.insert(key.clone(), 11);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store.clone()).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
assert_eq!(scanner_cycle_recovery_status().classification.as_deref(), Some("corrupt"));
|
||||||
|
assert!(
|
||||||
|
scanner_cycle_recovery_status()
|
||||||
|
.reason
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|reason| reason.contains("oversized"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let marker_key = memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_BLOOM_RECOVERY_PATH.as_str());
|
||||||
|
store.objects.lock().await.remove(&marker_key);
|
||||||
|
store.objects.lock().await.insert(key.clone(), vec![1]);
|
||||||
|
store.revisions.lock().await.insert(key.clone(), 12);
|
||||||
|
store.non_regular_objects.lock().await.insert(key);
|
||||||
|
// The object contract exposes a non-regular object as `is_dir`; local
|
||||||
|
// backends reject symlink/reparse entries before they become an object.
|
||||||
|
assert!(matches!(
|
||||||
|
load_scanner_cycle_state_for_startup(store).await,
|
||||||
|
ScannerCycleStateStartup::Blocked
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scanner_startup_uses_primary_and_backup_usage_floor() {
|
async fn scanner_startup_uses_primary_and_backup_usage_floor() {
|
||||||
let store = Arc::new(MemoryConfigStore::default());
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
@@ -855,6 +1708,31 @@ async fn scanner_startup_uses_primary_and_backup_usage_floor() {
|
|||||||
assert_eq!(epoch, 11);
|
assert_eq!(epoch, 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_usage_floor_ignores_older_backup_after_primary_epoch_fence() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let backup_path = format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||||
|
for (path, epoch, cycle) in [(DATA_USAGE_OBJ_NAME_PATH.as_str(), 8, 100), (backup_path.as_str(), 7, 10_000)] {
|
||||||
|
store.objects.lock().await.insert(
|
||||||
|
memory_config_key(RUSTFS_META_BUCKET, path),
|
||||||
|
serde_json::to_vec(&DataUsageInfo {
|
||||||
|
scanner_epoch: Some(epoch),
|
||||||
|
scanner_cycle: Some(cycle),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.expect("usage snapshot should encode"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
persisted_usage_floor(store).await.expect("usage floor should load"),
|
||||||
|
PersistedUsageFloor {
|
||||||
|
next_cycle: 101,
|
||||||
|
leader_epoch: 8,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_startup_treats_incomplete_usage_snapshot_as_cold() {
|
fn scanner_startup_treats_incomplete_usage_snapshot_as_cold() {
|
||||||
let mut legacy = complete_usage_with_bucket_count(Some(std::time::SystemTime::now()), 1);
|
let mut legacy = complete_usage_with_bucket_count(Some(std::time::SystemTime::now()), 1);
|
||||||
@@ -987,6 +1865,15 @@ async fn scanner_usage_floor_fails_closed_on_corrupt_or_exhausted_usage_state()
|
|||||||
|
|
||||||
assert!(persisted_usage_floor(store.clone()).await.is_err());
|
assert!(persisted_usage_floor(store.clone()).await.is_err());
|
||||||
|
|
||||||
|
store.objects.lock().await.insert(
|
||||||
|
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||||
|
br#"{}"#.to_vec(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
persisted_usage_floor(store.clone()).await.is_err(),
|
||||||
|
"a structurally incomplete usage snapshot must not be treated as an empty floor"
|
||||||
|
);
|
||||||
|
|
||||||
store.objects.lock().await.insert(
|
store.objects.lock().await.insert(
|
||||||
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
|
||||||
serde_json::to_vec(&DataUsageInfo {
|
serde_json::to_vec(&DataUsageInfo {
|
||||||
@@ -1244,6 +2131,22 @@ async fn test_leadership_claim_preserves_usage_epoch_floor_across_old_epoch_conf
|
|||||||
assert_eq!(store.put_counts.lock().await.get(&key), Some(&3));
|
assert_eq!(store.put_counts.lock().await.get(&key), Some(&3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_leadership_claim_rejects_terminal_epoch() {
|
||||||
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
|
let ctx = CancellationToken::new();
|
||||||
|
let mut revision = DataUsageCacheRevision::Missing;
|
||||||
|
let mut cycle = CurrentCycle {
|
||||||
|
next: 12,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut persisted_epoch = u64::MAX - 1;
|
||||||
|
|
||||||
|
assert!(!claim_scanner_leadership(&ctx, store.clone(), &mut cycle, &mut revision, &mut persisted_epoch).await);
|
||||||
|
assert_eq!(persisted_epoch, u64::MAX - 1);
|
||||||
|
assert!(read_config(store, &DATA_USAGE_BLOOM_NAME_PATH).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_leadership_claim_confirms_commit_after_returned_error() {
|
async fn test_leadership_claim_confirms_commit_after_returned_error() {
|
||||||
let store = Arc::new(MemoryConfigStore::default());
|
let store = Arc::new(MemoryConfigStore::default());
|
||||||
@@ -2975,6 +3878,24 @@ fn superseded_retry_backoff_grows_from_the_default_cycle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn corrupt_cycle_state_backoff_uses_virtual_clock() {
|
||||||
|
let mut backoff = ScannerRetryBackoff::default();
|
||||||
|
backoff.record_retryable_cycle(true);
|
||||||
|
let first_delay = backoff
|
||||||
|
.retry_interval(Duration::from_secs(60))
|
||||||
|
.expect("the first recovery retry should be scheduled");
|
||||||
|
assert_eq!(first_delay, Duration::from_secs(5));
|
||||||
|
|
||||||
|
let deadline = Instant::now() + first_delay;
|
||||||
|
assert!(Instant::now() < deadline);
|
||||||
|
tokio::time::advance(first_delay).await;
|
||||||
|
assert!(Instant::now() >= deadline);
|
||||||
|
|
||||||
|
backoff.record_retryable_cycle(true);
|
||||||
|
assert_eq!(backoff.retry_interval(Duration::from_secs(60)), Some(Duration::from_secs(10)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_cycle_wait_plan_drives_growth_resets_and_bitrot_cap() {
|
fn scanner_cycle_wait_plan_drives_growth_resets_and_bitrot_cap() {
|
||||||
let runtime_config = ScannerRuntimeConfig {
|
let runtime_config = ScannerRuntimeConfig {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub use bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOption
|
|||||||
pub use capability::{CapabilitySnapshotError, CapabilityState, CapabilityStatus};
|
pub use capability::{CapabilitySnapshotError, CapabilityState, CapabilityStatus};
|
||||||
pub use error::{StorageErrorCode, StorageResult};
|
pub use error::{StorageErrorCode, StorageResult};
|
||||||
pub use multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo};
|
pub use multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo};
|
||||||
|
pub use object::DeleteAccounting;
|
||||||
pub use object::ObjectLockDeleteOptions;
|
pub use object::ObjectLockDeleteOptions;
|
||||||
pub use object::{DeletedObject, ObjectToDelete};
|
pub use object::{DeletedObject, ObjectToDelete};
|
||||||
pub use object::{ExpirationOptions, TransitionedObject};
|
pub use object::{ExpirationOptions, TransitionedObject};
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ pub struct DeletedObject {
|
|||||||
pub force_delete_generation: Option<i64>,
|
pub force_delete_generation: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accounting identity returned by the internal commit-time delete path.
|
||||||
|
///
|
||||||
|
/// This is carried separately from [`DeletedObject`] so adding quota details
|
||||||
|
/// does not change the source shape of the public S3 delete result contract.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeleteAccounting {
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub version_id: Option<Uuid>,
|
||||||
|
pub removed_current_object: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl DeletedObject {
|
impl DeletedObject {
|
||||||
pub fn version_purge_status(&self) -> VersionPurgeStatusType {
|
pub fn version_purge_status(&self) -> VersionPurgeStatusType {
|
||||||
self.replication_state
|
self.replication_state
|
||||||
@@ -341,6 +352,19 @@ pub trait ObjectOperations: Send + Sync + fmt::Debug {
|
|||||||
objects: Vec<Self::ObjectToDelete>,
|
objects: Vec<Self::ObjectToDelete>,
|
||||||
opts: Self::ObjectOptions,
|
opts: Self::ObjectOptions,
|
||||||
) -> (Vec<Self::DeletedObject>, Vec<Option<Self::Error>>);
|
) -> (Vec<Self::DeletedObject>, Vec<Option<Self::Error>>);
|
||||||
|
/// Delete objects and optionally return commit-time accounting identities.
|
||||||
|
/// The default preserves the ordinary delete contract for implementations
|
||||||
|
/// that do not expose storage-level accounting details.
|
||||||
|
async fn delete_objects_with_accounting(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
objects: Vec<Self::ObjectToDelete>,
|
||||||
|
opts: Self::ObjectOptions,
|
||||||
|
) -> (Vec<Self::DeletedObject>, Vec<Option<Self::Error>>, Vec<Option<DeleteAccounting>>) {
|
||||||
|
let object_count = objects.len();
|
||||||
|
let (deleted, errors) = self.delete_objects(bucket, objects, opts).await;
|
||||||
|
(deleted, errors, vec![None; object_count])
|
||||||
|
}
|
||||||
async fn put_object_metadata(
|
async fn put_object_metadata(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ mod tests {
|
|||||||
let _list_remote_target_handler = replication::ListRemoteTargetHandler {};
|
let _list_remote_target_handler = replication::ListRemoteTargetHandler {};
|
||||||
let _remove_remote_target_handler = replication::RemoveRemoteTargetHandler {};
|
let _remove_remote_target_handler = replication::RemoveRemoteTargetHandler {};
|
||||||
let _scanner_status_handler = scanner::ScannerStatusHandler {};
|
let _scanner_status_handler = scanner::ScannerStatusHandler {};
|
||||||
|
let _scanner_cycle_state_reset_handler = scanner::ScannerCycleStateResetHandler {};
|
||||||
let _ilm_expiry_status_handler = scanner::IlmExpiryStatusHandler {};
|
let _ilm_expiry_status_handler = scanner::IlmExpiryStatusHandler {};
|
||||||
let _manual_transition_handler = ilm_transition::ManualTransitionRunHandler {};
|
let _manual_transition_handler = ilm_transition::ManualTransitionRunHandler {};
|
||||||
let _manual_transition_status_handler = ilm_transition::ManualTransitionJobStatusHandler {};
|
let _manual_transition_status_handler = ilm_transition::ManualTransitionJobStatusHandler {};
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ enum TargetUpdateOp {
|
|||||||
/// Connection group: credentials plus endpoint, target bucket, and TLS settings.
|
/// Connection group: credentials plus endpoint, target bucket, and TLS settings.
|
||||||
Credentials,
|
Credentials,
|
||||||
Sync,
|
Sync,
|
||||||
|
/// Per-target read-proxy opt-out (`disableProxy`).
|
||||||
|
Proxy,
|
||||||
Bandwidth,
|
Bandwidth,
|
||||||
Path,
|
Path,
|
||||||
}
|
}
|
||||||
@@ -81,12 +83,13 @@ fn parse_remote_target_update_ops(queries: &HashMap<String, String>) -> S3Result
|
|||||||
const SUPPORTED_OPS: &[(&str, TargetUpdateOp)] = &[
|
const SUPPORTED_OPS: &[(&str, TargetUpdateOp)] = &[
|
||||||
("creds", TargetUpdateOp::Credentials),
|
("creds", TargetUpdateOp::Credentials),
|
||||||
("sync", TargetUpdateOp::Sync),
|
("sync", TargetUpdateOp::Sync),
|
||||||
|
("proxy", TargetUpdateOp::Proxy),
|
||||||
("bandwidth", TargetUpdateOp::Bandwidth),
|
("bandwidth", TargetUpdateOp::Bandwidth),
|
||||||
("path", TargetUpdateOp::Path),
|
("path", TargetUpdateOp::Path),
|
||||||
];
|
];
|
||||||
// Present in the MinIO wire contract, but they drive target fields this
|
// Present in the MinIO wire contract, but they drive target fields this
|
||||||
// version rejects as unsupported — fail loudly instead of silently ignoring.
|
// version rejects as unsupported — fail loudly instead of silently ignoring.
|
||||||
const UNSUPPORTED_OPS: &[&str] = &["proxy", "healthcheck", "edge", "edgeSyncBeforeExpiry"];
|
const UNSUPPORTED_OPS: &[&str] = &["healthcheck", "edge", "edgeSyncBeforeExpiry"];
|
||||||
|
|
||||||
for key in UNSUPPORTED_OPS {
|
for key in UNSUPPORTED_OPS {
|
||||||
if queries.get(*key).is_some_and(|value| value == "true") {
|
if queries.get(*key).is_some_and(|value| value == "true") {
|
||||||
@@ -312,11 +315,10 @@ impl RemoteTargetRequest {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (unsupported, configured) in
|
for (unsupported, configured) in REMOTE_TARGET_UNSUPPORTED_FIELDS
|
||||||
REMOTE_TARGET_UNSUPPORTED_FIELDS
|
.iter()
|
||||||
.iter()
|
.copied()
|
||||||
.copied()
|
.zip([self.edge, self.edge_sync_before_expiry])
|
||||||
.zip([self.disable_proxy, self.edge, self.edge_sync_before_expiry])
|
|
||||||
{
|
{
|
||||||
if configured {
|
if configured {
|
||||||
return Err(s3_error!(
|
return Err(s3_error!(
|
||||||
@@ -702,6 +704,7 @@ impl Operation for SetRemoteTargetHandler {
|
|||||||
target.deployment_id = remote_target.deployment_id.clone();
|
target.deployment_id = remote_target.deployment_id.clone();
|
||||||
}
|
}
|
||||||
TargetUpdateOp::Sync => target.replication_sync = remote_target.replication_sync,
|
TargetUpdateOp::Sync => target.replication_sync = remote_target.replication_sync,
|
||||||
|
TargetUpdateOp::Proxy => target.disable_proxy = remote_target.disable_proxy,
|
||||||
TargetUpdateOp::Bandwidth => target.bandwidth_limit = remote_target.bandwidth_limit,
|
TargetUpdateOp::Bandwidth => target.bandwidth_limit = remote_target.bandwidth_limit,
|
||||||
TargetUpdateOp::Path => target.path = remote_target.path.clone(),
|
TargetUpdateOp::Path => target.path = remote_target.path.clone(),
|
||||||
}
|
}
|
||||||
@@ -1520,6 +1523,7 @@ mod tests {
|
|||||||
("update", "true"),
|
("update", "true"),
|
||||||
("creds", "true"),
|
("creds", "true"),
|
||||||
("sync", "true"),
|
("sync", "true"),
|
||||||
|
("proxy", "true"),
|
||||||
("bandwidth", "true"),
|
("bandwidth", "true"),
|
||||||
("path", "true"),
|
("path", "true"),
|
||||||
]))
|
]))
|
||||||
@@ -1529,6 +1533,7 @@ mod tests {
|
|||||||
vec![
|
vec![
|
||||||
TargetUpdateOp::Credentials,
|
TargetUpdateOp::Credentials,
|
||||||
TargetUpdateOp::Sync,
|
TargetUpdateOp::Sync,
|
||||||
|
TargetUpdateOp::Proxy,
|
||||||
TargetUpdateOp::Bandwidth,
|
TargetUpdateOp::Bandwidth,
|
||||||
TargetUpdateOp::Path
|
TargetUpdateOp::Path
|
||||||
]
|
]
|
||||||
@@ -2070,7 +2075,6 @@ mod tests {
|
|||||||
("credentials.session_token", serde_json::json!("session-token")),
|
("credentials.session_token", serde_json::json!("session-token")),
|
||||||
("credentials.expiration", serde_json::json!("2026-01-01T00:00:00Z")),
|
("credentials.expiration", serde_json::json!("2026-01-01T00:00:00Z")),
|
||||||
("api", serde_json::json!("s3v2")),
|
("api", serde_json::json!("s3v2")),
|
||||||
("disableProxy", serde_json::json!(true)),
|
|
||||||
("edge", serde_json::json!(true)),
|
("edge", serde_json::json!(true)),
|
||||||
("edgeSyncBeforeExpiry", serde_json::json!(true)),
|
("edgeSyncBeforeExpiry", serde_json::json!(true)),
|
||||||
] {
|
] {
|
||||||
@@ -2300,6 +2304,44 @@ mod tests {
|
|||||||
assert!(!REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(&"healthCheckDuration"));
|
assert!(!REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(&"healthCheckDuration"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_target_disable_proxy_is_declared_writable_edge_stays_unsupported() {
|
||||||
|
assert!(REMOTE_TARGET_WRITABLE_FIELDS.contains(&"disableProxy"));
|
||||||
|
assert!(!REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(&"disableProxy"));
|
||||||
|
// edge sync has no implementation behind it — it must stay rejected.
|
||||||
|
assert!(REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(&"edge"));
|
||||||
|
assert!(REMOTE_TARGET_UNSUPPORTED_FIELDS.contains(&"edgeSyncBeforeExpiry"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_target_create_accepts_disable_proxy() {
|
||||||
|
let mut request = valid_remote_target_request();
|
||||||
|
request["disableProxy"] = serde_json::json!(true);
|
||||||
|
|
||||||
|
let target = serde_json::from_value::<RemoteTargetRequest>(request)
|
||||||
|
.expect("request should deserialize")
|
||||||
|
.into_bucket_target()
|
||||||
|
.expect("disableProxy is a supported per-target read-proxy opt-out");
|
||||||
|
|
||||||
|
assert!(target.disable_proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_body_with_proxy_op_toggles_disable_proxy_without_credentials() {
|
||||||
|
// Mirrors the other partial-update groups: a proxy-only update body may
|
||||||
|
// omit the connection fields entirely.
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"arn": "arn:rustfs:replication:us-east-1:dep:target",
|
||||||
|
"type": "replication",
|
||||||
|
"disableProxy": true
|
||||||
|
});
|
||||||
|
let request: RemoteTargetRequest = serde_json::from_value(body).expect("partial update body should deserialize");
|
||||||
|
let target = request
|
||||||
|
.into_update_bucket_target(&[TargetUpdateOp::Proxy])
|
||||||
|
.expect("proxy-only update must not require credentials");
|
||||||
|
assert!(target.disable_proxy);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_target_capability_fields_do_not_overlap() {
|
fn remote_target_capability_fields_do_not_overlap() {
|
||||||
for field in REMOTE_TARGET_UNSUPPORTED_FIELDS {
|
for field in REMOTE_TARGET_UNSUPPORTED_FIELDS {
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::admin::auth::authorize_admin_request;
|
use crate::admin::auth::authorize_admin_request;
|
||||||
|
use crate::admin::handlers::supervise_admin_mutation;
|
||||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||||
use crate::admin::runtime_sources::current_scanner_metrics_report;
|
use crate::admin::runtime_sources::{
|
||||||
|
app_context_from_req, current_object_store_handle_for_context, current_scanner_metrics_report,
|
||||||
|
};
|
||||||
use crate::module_switches::{ENV_SCANNER_ENABLED, scanner_enabled_from_env};
|
use crate::module_switches::{ENV_SCANNER_ENABLED, scanner_enabled_from_env};
|
||||||
use crate::server::ADMIN_PREFIX;
|
use crate::server::ADMIN_PREFIX;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -22,11 +25,13 @@ use http::{HeaderMap, HeaderValue};
|
|||||||
use hyper::{Method, StatusCode};
|
use hyper::{Method, StatusCode};
|
||||||
use matchit::Params;
|
use matchit::Params;
|
||||||
use rustfs_common::metrics::{ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport};
|
use rustfs_common::metrics::{ScannerLifecycleExpirySnapshot, ScannerMaintenanceControlSnapshot, ScannerMetricsReport};
|
||||||
|
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
|
||||||
use rustfs_credentials::Credentials;
|
use rustfs_credentials::Credentials;
|
||||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||||
use s3s::header::CONTENT_TYPE;
|
use s3s::header::CONTENT_TYPE;
|
||||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
const JSON_CONTENT_TYPE: &str = "application/json";
|
const JSON_CONTENT_TYPE: &str = "application/json";
|
||||||
|
|
||||||
@@ -38,6 +43,13 @@ struct ScannerStatusResponse {
|
|||||||
metrics: ScannerMetricsReport,
|
metrics: ScannerMetricsReport,
|
||||||
cycle_schedule: rustfs_scanner::ScannerCycleScheduleStatus,
|
cycle_schedule: rustfs_scanner::ScannerCycleScheduleStatus,
|
||||||
runtime_config: rustfs_scanner::runtime_config::ScannerRuntimeConfigStatus,
|
runtime_config: rustfs_scanner::runtime_config::ScannerRuntimeConfigStatus,
|
||||||
|
cycle_recovery: rustfs_scanner::ScannerCycleRecoveryStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct ScannerCycleResetRequest {
|
||||||
|
mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -117,6 +129,7 @@ fn scanner_status_response(
|
|||||||
metrics,
|
metrics,
|
||||||
cycle_schedule,
|
cycle_schedule,
|
||||||
runtime_config,
|
runtime_config,
|
||||||
|
cycle_recovery: rustfs_scanner::scanner::scanner_cycle_recovery_status(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +157,11 @@ pub fn register_scanner_route(r: &mut S3Router<AdminOperation>) -> std::io::Resu
|
|||||||
format!("{ADMIN_PREFIX}/v3/scanner/status").as_str(),
|
format!("{ADMIN_PREFIX}/v3/scanner/status").as_str(),
|
||||||
AdminOperation(&ScannerStatusHandler {}),
|
AdminOperation(&ScannerStatusHandler {}),
|
||||||
)?;
|
)?;
|
||||||
|
r.insert(
|
||||||
|
Method::POST,
|
||||||
|
format!("{ADMIN_PREFIX}/v3/scanner/cycle-state/reset").as_str(),
|
||||||
|
AdminOperation(&ScannerCycleStateResetHandler {}),
|
||||||
|
)?;
|
||||||
r.insert(
|
r.insert(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
format!("{ADMIN_PREFIX}/v3/ilm/expiry/status").as_str(),
|
format!("{ADMIN_PREFIX}/v3/ilm/expiry/status").as_str(),
|
||||||
@@ -163,6 +181,13 @@ async fn validate_scanner_status_request(req: &S3Request<Body>) -> S3Result<Cred
|
|||||||
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]).await
|
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::ServerInfoAdminAction)]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn validate_scanner_reset_request(req: &S3Request<Body>) -> S3Result<Credentials> {
|
||||||
|
if req.credentials.is_none() {
|
||||||
|
return Err(s3_error!(InvalidRequest, "missing credentials"));
|
||||||
|
}
|
||||||
|
authorize_admin_request(req, vec![Action::AdminAction(AdminAction::ConfigUpdateAdminAction)]).await
|
||||||
|
}
|
||||||
|
|
||||||
fn json_response(body: Vec<u8>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
fn json_response(body: Vec<u8>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
let content_type = HeaderValue::from_str(JSON_CONTENT_TYPE)
|
let content_type = HeaderValue::from_str(JSON_CONTENT_TYPE)
|
||||||
@@ -192,6 +217,37 @@ impl Operation for ScannerStatusHandler {
|
|||||||
|
|
||||||
pub struct IlmExpiryStatusHandler {}
|
pub struct IlmExpiryStatusHandler {}
|
||||||
|
|
||||||
|
pub struct ScannerCycleStateResetHandler {}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Operation for ScannerCycleStateResetHandler {
|
||||||
|
async fn call(&self, mut req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
let _cred = validate_scanner_reset_request(&req).await?;
|
||||||
|
let body = req
|
||||||
|
.input
|
||||||
|
.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid reset request body: {err}")))?;
|
||||||
|
let reset = serde_json::from_slice::<ScannerCycleResetRequest>(&body)
|
||||||
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid reset request body: {err}")))?;
|
||||||
|
if reset.mode != "full-rescan" {
|
||||||
|
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "reset mode must be full-rescan"));
|
||||||
|
}
|
||||||
|
let context = app_context_from_req(&req)
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "storage layer not initialized"))?;
|
||||||
|
let store = current_object_store_handle_for_context(Some(context.as_ref()))
|
||||||
|
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "storage layer not initialized"))?;
|
||||||
|
supervise_admin_mutation("scanner cycle state reset", async move {
|
||||||
|
rustfs_scanner::scanner::reset_scanner_cycle_recovery(CancellationToken::new(), store)
|
||||||
|
.await
|
||||||
|
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))?;
|
||||||
|
Ok::<_, S3Error>(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
json_response(br#"{"status":"reset","mode":"full-rescan"}"#.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl Operation for IlmExpiryStatusHandler {
|
impl Operation for IlmExpiryStatusHandler {
|
||||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
@@ -237,6 +293,38 @@ mod tests {
|
|||||||
assert_eq!(err.message(), Some("missing credentials"));
|
assert_eq!(err.message(), Some("missing credentials"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_reset_gate_rejects_missing_credentials() {
|
||||||
|
let req = S3Request {
|
||||||
|
input: Body::from(String::new()),
|
||||||
|
method: Method::POST,
|
||||||
|
uri: http::Uri::from_static("/rustfs/admin/v3/scanner/cycle-state/reset"),
|
||||||
|
headers: HeaderMap::new(),
|
||||||
|
extensions: http::Extensions::new(),
|
||||||
|
credentials: None,
|
||||||
|
region: None,
|
||||||
|
service: None,
|
||||||
|
trailing_headers: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = validate_scanner_reset_request(&req)
|
||||||
|
.await
|
||||||
|
.expect_err("a reset request without credentials must be rejected");
|
||||||
|
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||||
|
assert_eq!(err.message(), Some("missing credentials"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_reset_requires_full_rescan_or_verified_cursor() {
|
||||||
|
let full_rescan: ScannerCycleResetRequest =
|
||||||
|
serde_json::from_str(r#"{"mode":"full-rescan"}"#).expect("full rescan must be accepted");
|
||||||
|
assert_eq!(full_rescan.mode, "full-rescan");
|
||||||
|
let cursor: ScannerCycleResetRequest =
|
||||||
|
serde_json::from_str(r#"{"mode":"cursor"}"#).expect("mode validation belongs to the handler");
|
||||||
|
assert_ne!(cursor.mode, "full-rescan");
|
||||||
|
assert!(serde_json::from_str::<ScannerCycleResetRequest>(r#"{"mode":"full-rescan","cursor":"untrusted"}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_disabled_reason_reports_startup_env_key() {
|
fn scanner_disabled_reason_reports_startup_env_key() {
|
||||||
assert_eq!(scanner_disabled_reason(true), None);
|
assert_eq!(scanner_disabled_reason(true), None);
|
||||||
@@ -304,6 +392,11 @@ mod tests {
|
|||||||
assert_eq!(encoded["cycle_schedule"]["effective_interval_seconds"], 0);
|
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_enabled"], false);
|
||||||
assert_eq!(encoded["cycle_schedule"]["clean_idle_backoff_multiplier"], 1);
|
assert_eq!(encoded["cycle_schedule"]["clean_idle_backoff_multiplier"], 1);
|
||||||
|
assert_eq!(encoded["cycle_recovery"]["state"], "healthy");
|
||||||
|
assert_eq!(
|
||||||
|
encoded["cycle_recovery"]["quarantine_path"],
|
||||||
|
rustfs_scanner::DATA_USAGE_BLOOM_RECOVERY_PATH.as_str()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1262,7 +1262,9 @@ mod tests {
|
|||||||
assert_eq!(response.summary.manual_transition_jobs.state, CapabilityState::Supported);
|
assert_eq!(response.summary.manual_transition_jobs.state, CapabilityState::Supported);
|
||||||
assert_eq!(response.replication.contract_version, 1);
|
assert_eq!(response.replication.contract_version, 1);
|
||||||
assert_eq!(response.replication.bucket_replication.contract_version, 1);
|
assert_eq!(response.replication.bucket_replication.contract_version, 1);
|
||||||
assert_eq!(response.replication.remote_targets.contract_version, 1);
|
// v2: disableProxy moved from unsupported to writable (per-target
|
||||||
|
// read-proxy opt-out reached the admin API).
|
||||||
|
assert_eq!(response.replication.remote_targets.contract_version, 2);
|
||||||
assert_eq!(response.replication.bucket_replication.status.state, CapabilityState::Supported);
|
assert_eq!(response.replication.bucket_replication.status.state, CapabilityState::Supported);
|
||||||
assert_eq!(response.replication.remote_targets.status.state, CapabilityState::Supported);
|
assert_eq!(response.replication.remote_targets.status.state, CapabilityState::Supported);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1293,7 +1295,15 @@ mod tests {
|
|||||||
.remote_targets
|
.remote_targets
|
||||||
.fields
|
.fields
|
||||||
.iter()
|
.iter()
|
||||||
.any(|field| field.name == "disableProxy" && field.state == super::ReplicationFieldState::Unsupported)
|
.any(|field| field.name == "disableProxy" && field.state == super::ReplicationFieldState::Supported)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.replication
|
||||||
|
.remote_targets
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.any(|field| field.name == "edge" && field.state == super::ReplicationFieldState::Unsupported)
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
response
|
response
|
||||||
@@ -1364,7 +1374,7 @@ mod tests {
|
|||||||
assert_eq!(value["summary"]["manual_transition_jobs"]["state"], "supported");
|
assert_eq!(value["summary"]["manual_transition_jobs"]["state"], "supported");
|
||||||
assert_eq!(value["replication"]["contract_version"], 1);
|
assert_eq!(value["replication"]["contract_version"], 1);
|
||||||
assert_eq!(value["replication"]["bucket_replication"]["contract_version"], 1);
|
assert_eq!(value["replication"]["bucket_replication"]["contract_version"], 1);
|
||||||
assert_eq!(value["replication"]["remote_targets"]["contract_version"], 1);
|
assert_eq!(value["replication"]["remote_targets"]["contract_version"], 2);
|
||||||
assert_eq!(value["replication"]["bucket_replication"]["status"]["state"], "supported");
|
assert_eq!(value["replication"]["bucket_replication"]["status"]["state"], "supported");
|
||||||
assert_eq!(value["replication"]["remote_targets"]["status"]["state"], "supported");
|
assert_eq!(value["replication"]["remote_targets"]["status"]["state"], "supported");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1383,7 +1393,14 @@ mod tests {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.expect("remote target fields should be an array")
|
.expect("remote target fields should be an array")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|field| field["name"] == "disableProxy" && field["state"] == "unsupported")
|
.any(|field| field["name"] == "disableProxy" && field["state"] == "supported")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
value["replication"]["remote_targets"]["fields"]
|
||||||
|
.as_array()
|
||||||
|
.expect("remote target fields should be an array")
|
||||||
|
.iter()
|
||||||
|
.any(|field| field["name"] == "edge" && field["state"] == "unsupported")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
value["replication"]["remote_targets"]["fields"]
|
value["replication"]["remote_targets"]["fields"]
|
||||||
|
|||||||
@@ -428,6 +428,12 @@ pub const ADMIN_ROUTE_POLICY_SPECS: &[AdminRouteSpec] = &[
|
|||||||
admin(HttpMethod::Get, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High),
|
admin(HttpMethod::Get, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High),
|
||||||
admin(HttpMethod::Put, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High),
|
admin(HttpMethod::Put, "/rustfs/admin/v3/config", CONFIG_UPDATE, RouteRiskLevel::High),
|
||||||
admin(HttpMethod::Get, "/rustfs/admin/v3/scanner/status", SERVER_INFO, RouteRiskLevel::Sensitive),
|
admin(HttpMethod::Get, "/rustfs/admin/v3/scanner/status", SERVER_INFO, RouteRiskLevel::Sensitive),
|
||||||
|
admin(
|
||||||
|
HttpMethod::Post,
|
||||||
|
"/rustfs/admin/v3/scanner/cycle-state/reset",
|
||||||
|
CONFIG_UPDATE,
|
||||||
|
RouteRiskLevel::High,
|
||||||
|
),
|
||||||
admin(
|
admin(
|
||||||
HttpMethod::Get,
|
HttpMethod::Get,
|
||||||
"/rustfs/admin/v3/ilm/expiry/status",
|
"/rustfs/admin/v3/ilm/expiry/status",
|
||||||
@@ -2020,6 +2026,12 @@ mod tests {
|
|||||||
assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/expiry/status", SET_TIER);
|
assert_not_action(HttpMethod::Get, "/rustfs/admin/v3/ilm/expiry/status", SET_TIER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_policy_requires_config_update_for_scanner_cycle_reset() {
|
||||||
|
assert_action(HttpMethod::Post, "/rustfs/admin/v3/scanner/cycle-state/reset", CONFIG_UPDATE);
|
||||||
|
assert_not_action(HttpMethod::Post, "/rustfs/admin/v3/scanner/cycle-state/reset", SERVER_INFO);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn route_policy_uses_tier_actions_for_transition_routes() {
|
fn route_policy_uses_tier_actions_for_transition_routes() {
|
||||||
assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER);
|
assert_action(HttpMethod::Post, "/rustfs/admin/v3/ilm/transition/run", SET_TIER);
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ fn expected_admin_route_matrix() -> Vec<RouteMatrixEntry> {
|
|||||||
admin_route(Method::GET, "/v3/config"),
|
admin_route(Method::GET, "/v3/config"),
|
||||||
admin_route(Method::PUT, "/v3/config"),
|
admin_route(Method::PUT, "/v3/config"),
|
||||||
admin_route(Method::GET, "/v3/scanner/status"),
|
admin_route(Method::GET, "/v3/scanner/status"),
|
||||||
|
admin_route(Method::POST, "/v3/scanner/cycle-state/reset"),
|
||||||
admin_route(Method::GET, "/v3/audit/target/list"),
|
admin_route(Method::GET, "/v3/audit/target/list"),
|
||||||
admin_route_sample(
|
admin_route_sample(
|
||||||
Method::PUT,
|
Method::PUT,
|
||||||
@@ -879,6 +880,7 @@ fn test_register_routes_cover_representative_admin_paths() {
|
|||||||
assert_route(&router, Method::GET, &admin_path("/v3/config"));
|
assert_route(&router, Method::GET, &admin_path("/v3/config"));
|
||||||
assert_route(&router, Method::PUT, &admin_path("/v3/config"));
|
assert_route(&router, Method::PUT, &admin_path("/v3/config"));
|
||||||
assert_route(&router, Method::GET, &admin_path("/v3/scanner/status"));
|
assert_route(&router, Method::GET, &admin_path("/v3/scanner/status"));
|
||||||
|
assert_route(&router, Method::POST, &admin_path("/v3/scanner/cycle-state/reset"));
|
||||||
assert_route(&router, Method::GET, &admin_path("/v3/ilm/expiry/status"));
|
assert_route(&router, Method::GET, &admin_path("/v3/ilm/expiry/status"));
|
||||||
assert_route(&router, Method::POST, &admin_path("/v3/ilm/transition/run"));
|
assert_route(&router, Method::POST, &admin_path("/v3/ilm/transition/run"));
|
||||||
assert_route(
|
assert_route(
|
||||||
@@ -1367,6 +1369,7 @@ fn test_admin_alias_paths_match_existing_admin_routes() {
|
|||||||
(Method::GET, compat_admin_alias_path("/v3/config")),
|
(Method::GET, compat_admin_alias_path("/v3/config")),
|
||||||
(Method::PUT, compat_admin_alias_path("/v3/config")),
|
(Method::PUT, compat_admin_alias_path("/v3/config")),
|
||||||
(Method::GET, compat_admin_alias_path("/v3/scanner/status")),
|
(Method::GET, compat_admin_alias_path("/v3/scanner/status")),
|
||||||
|
(Method::POST, compat_admin_alias_path("/v3/scanner/cycle-state/reset")),
|
||||||
(Method::GET, compat_admin_alias_path("/v3/ilm/expiry/status")),
|
(Method::GET, compat_admin_alias_path("/v3/ilm/expiry/status")),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1021,7 +1021,7 @@ fn build_list_objects_v2_metadata_output(
|
|||||||
object: Object {
|
object: Object {
|
||||||
key: Some(encode_list_objects_v2_value(&object.name, encoding_type)),
|
key: Some(encode_list_objects_v2_value(&object.name, encoding_type)),
|
||||||
last_modified: object.mod_time.map(Timestamp::from),
|
last_modified: object.mod_time.map(Timestamp::from),
|
||||||
size: Some(object.get_actual_size().unwrap_or_default()),
|
size: Some(object.get_actual_size_or_physical()),
|
||||||
e_tag: object.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
e_tag: object.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
||||||
storage_class: Some(ObjectStorageClass::from(
|
storage_class: Some(ObjectStorageClass::from(
|
||||||
object
|
object
|
||||||
|
|||||||
@@ -3969,6 +3969,55 @@ fn delete_creates_delete_marker(opts: &ObjectOptions) -> bool {
|
|||||||
opts.version_id.is_none() && opts.versioned && !opts.version_suspended
|
opts.version_id.is_none() && opts.versioned && !opts.version_suspended
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_removes_current_object(opts: &ObjectOptions) -> bool {
|
||||||
|
delete_request_targets_current(
|
||||||
|
opts.version_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|version_id| Uuid::parse_str(version_id).ok()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_request_targets_current(version_id: Option<Uuid>) -> bool {
|
||||||
|
version_id.is_none() || version_id.is_some_and(|version_id| version_id.is_nil())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum DeleteMemoryUpdate {
|
||||||
|
DeleteMarker,
|
||||||
|
Object { size: u64, removed_current_object: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_memory_update(
|
||||||
|
creates_delete_marker: bool,
|
||||||
|
committed_delete_marker: bool,
|
||||||
|
requested_current: bool,
|
||||||
|
accounting_size: Option<u64>,
|
||||||
|
removed_current_object: bool,
|
||||||
|
) -> Option<DeleteMemoryUpdate> {
|
||||||
|
if creates_delete_marker || (committed_delete_marker && requested_current) {
|
||||||
|
return Some(DeleteMemoryUpdate::DeleteMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
(!committed_delete_marker)
|
||||||
|
.then_some(accounting_size)
|
||||||
|
.flatten()
|
||||||
|
.map(|size| DeleteMemoryUpdate::Object {
|
||||||
|
size,
|
||||||
|
removed_current_object,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_delete_memory_update(bucket: &str, update: Option<DeleteMemoryUpdate>) {
|
||||||
|
match update {
|
||||||
|
Some(DeleteMemoryUpdate::DeleteMarker) => record_bucket_delete_marker_memory(bucket).await,
|
||||||
|
Some(DeleteMemoryUpdate::Object {
|
||||||
|
size,
|
||||||
|
removed_current_object,
|
||||||
|
}) => record_bucket_object_delete_memory(bucket, size, removed_current_object).await,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `DeleteObjects` is idempotent. A raw filesystem `NotFound` can cross the
|
/// `DeleteObjects` is idempotent. A raw filesystem `NotFound` can cross the
|
||||||
/// distributed delete path instead of its usual typed missing-object error.
|
/// distributed delete path instead of its usual typed missing-object error.
|
||||||
fn is_delete_objects_not_found(error: &EcstoreError) -> bool {
|
fn is_delete_objects_not_found(error: &EcstoreError) -> bool {
|
||||||
@@ -8409,8 +8458,6 @@ impl DefaultObjectUsecase {
|
|||||||
object: ObjectToDelete,
|
object: ObjectToDelete,
|
||||||
versioned: bool,
|
versioned: bool,
|
||||||
version_suspended: bool,
|
version_suspended: bool,
|
||||||
size: i64,
|
|
||||||
existing: Option<ObjectInfo>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the
|
// Phase 2 (bounded concurrency, backlog#929 / HP-8): collect the
|
||||||
@@ -8428,32 +8475,23 @@ impl DefaultObjectUsecase {
|
|||||||
skip_stat,
|
skip_stat,
|
||||||
} = prepared;
|
} = prepared;
|
||||||
let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name);
|
let synthetic_version_id = object.version_id.is_none() && is_dir_object(&object.object_name);
|
||||||
let (goi, source_missing) = if skip_stat {
|
if !skip_stat {
|
||||||
(ObjectInfo::default(), false)
|
|
||||||
} else {
|
|
||||||
match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await {
|
match store_ref.get_object_info(bucket_ref, &object.object_name, &opts).await {
|
||||||
Ok(res) => (res, false),
|
Ok(_) => {}
|
||||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
|
||||||
(ObjectInfo::default(), true)
|
|
||||||
}
|
|
||||||
Err(err) => return Err(ApiError::from(err)),
|
Err(err) => return Err(ApiError::from(err)),
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
let size = goi.size;
|
|
||||||
|
|
||||||
if synthetic_version_id {
|
if synthetic_version_id {
|
||||||
object.version_id = Some(Uuid::nil());
|
object.version_id = Some(Uuid::nil());
|
||||||
}
|
}
|
||||||
|
|
||||||
let existing = (!skip_stat && !source_missing).then_some(goi);
|
|
||||||
Ok::<_, ApiError>(AdmittedDelete {
|
Ok::<_, ApiError>(AdmittedDelete {
|
||||||
idx,
|
idx,
|
||||||
object,
|
object,
|
||||||
versioned: opts.versioned,
|
versioned: opts.versioned,
|
||||||
version_suspended: opts.version_suspended,
|
version_suspended: opts.version_suspended,
|
||||||
size,
|
|
||||||
existing,
|
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
.buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY)
|
.buffered(DELETE_OBJECTS_PRE_STAT_CONCURRENCY)
|
||||||
@@ -8464,15 +8502,11 @@ impl DefaultObjectUsecase {
|
|||||||
// per-key success/failure reporting is unchanged.
|
// per-key success/failure reporting is unchanged.
|
||||||
let mut object_to_delete = Vec::new();
|
let mut object_to_delete = Vec::new();
|
||||||
let mut object_to_delete_idx = Vec::new();
|
let mut object_to_delete_idx = Vec::new();
|
||||||
let mut object_sizes = Vec::new();
|
|
||||||
let mut existing_object_infos = Vec::new();
|
|
||||||
let mut object_versioning = Vec::new();
|
let mut object_versioning = Vec::new();
|
||||||
for admitted in admitted_deletes {
|
for admitted in admitted_deletes {
|
||||||
object_sizes.push(admitted.size);
|
|
||||||
object_to_delete_idx.push(admitted.idx);
|
object_to_delete_idx.push(admitted.idx);
|
||||||
object_versioning.push((admitted.versioned, admitted.version_suspended));
|
object_versioning.push((admitted.versioned, admitted.version_suspended));
|
||||||
object_to_delete.push(admitted.object);
|
object_to_delete.push(admitted.object);
|
||||||
existing_object_infos.push(admitted.existing);
|
|
||||||
}
|
}
|
||||||
let cache_adapter = self.object_data_cache();
|
let cache_adapter = self.object_data_cache();
|
||||||
let cache_keys_before_delete = object_to_delete
|
let cache_keys_before_delete = object_to_delete
|
||||||
@@ -8489,8 +8523,8 @@ impl DefaultObjectUsecase {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
apply_bucket_generation_guard(&req, &bucket, &mut storage_delete_opts)?;
|
apply_bucket_generation_guard(&req, &bucket, &mut storage_delete_opts)?;
|
||||||
let (dobjs, errs) = store
|
let (dobjs, errs, accounting) = store
|
||||||
.delete_objects_with_tier_delete_journal(&bucket, object_to_delete.clone(), storage_delete_opts)
|
.delete_objects_with_tier_delete_journal_and_accounting(&bucket, object_to_delete.clone(), storage_delete_opts)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _manager = get_concurrency_manager();
|
let _manager = get_concurrency_manager();
|
||||||
@@ -8515,17 +8549,16 @@ impl DefaultObjectUsecase {
|
|||||||
delete_results[didx].delete_object = Some(deleted_object.clone());
|
delete_results[didx].delete_object = Some(deleted_object.clone());
|
||||||
let (versioned, version_suspended) = object_versioning[i];
|
let (versioned, version_suspended) = object_versioning[i];
|
||||||
let creates_delete_marker = object_to_delete[i].version_id.is_none() && versioned && !version_suspended;
|
let creates_delete_marker = object_to_delete[i].version_id.is_none() && versioned && !version_suspended;
|
||||||
if creates_delete_marker {
|
let committed_delete_marker = dobjs[i].delete_marker;
|
||||||
record_bucket_delete_marker_memory(&bucket).await;
|
let delete_accounting = accounting.get(i).and_then(Option::as_ref);
|
||||||
} else {
|
let update = delete_memory_update(
|
||||||
let size = object_sizes[i].max(0) as u64;
|
creates_delete_marker,
|
||||||
record_bucket_object_delete_memory(
|
committed_delete_marker,
|
||||||
&bucket,
|
delete_request_targets_current(object_to_delete[i].version_id),
|
||||||
size,
|
delete_accounting.and_then(|value| value.size),
|
||||||
existing_object_infos[i].is_some() && object_to_delete[i].version_id.is_none(),
|
delete_accounting.is_some_and(|value| value.removed_current_object),
|
||||||
)
|
);
|
||||||
.await;
|
apply_delete_memory_update(&bucket, update).await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
delete_results[didx].error = Some(error);
|
delete_results[didx].error = Some(error);
|
||||||
@@ -8803,12 +8836,24 @@ impl DefaultObjectUsecase {
|
|||||||
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
|
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast in-memory update for immediate quota and admin usage consistency
|
// Fast in-memory update for immediate quota and admin usage consistency.
|
||||||
if delete_creates_delete_marker(&opts) {
|
// Prefix/force deletes and synthetic directory entries do not carry one
|
||||||
record_bucket_delete_marker_memory(&bucket).await;
|
// committed object identity; leave their cache delta to reconciliation.
|
||||||
|
let update = if force_delete || obj_info.name.is_empty() || synthetic_version_id {
|
||||||
|
None
|
||||||
} else {
|
} else {
|
||||||
record_bucket_object_delete_memory(&bucket, obj_info.size.max(0) as u64, opts.version_id.is_none()).await;
|
// The storage commit returns this object's metadata while its
|
||||||
}
|
// generation lock is held. Never fall back to a pre-delete stat:
|
||||||
|
// an overwrite can commit between that stat and this delete.
|
||||||
|
delete_memory_update(
|
||||||
|
delete_creates_delete_marker(&opts),
|
||||||
|
obj_info.delete_marker,
|
||||||
|
opts.version_id.is_none(),
|
||||||
|
quota_object_size(&obj_info).ok(),
|
||||||
|
delete_removes_current_object(&opts),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
apply_delete_memory_update(&bucket, update).await;
|
||||||
|
|
||||||
if obj_info.name.is_empty() {
|
if obj_info.name.is_empty() {
|
||||||
if let Some((operation_id, target_arns, generation)) = force_delete_intent {
|
if let Some((operation_id, target_arns, generation)) = force_delete_intent {
|
||||||
@@ -17861,6 +17906,158 @@ mod tests {
|
|||||||
assert!(!can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), false));
|
assert!(!can_skip_delete_objects_pre_stat(false, &delete_marker_creating_opts(), false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_accounting_recognizes_explicit_null_as_current_object() {
|
||||||
|
let opts = ObjectOptions {
|
||||||
|
version_id: Some(Uuid::nil().to_string()),
|
||||||
|
version_suspended: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(delete_removes_current_object(&opts));
|
||||||
|
assert!(delete_request_targets_current(Some(Uuid::nil())));
|
||||||
|
assert!(!delete_request_targets_current(Some(Uuid::new_v4())));
|
||||||
|
assert!(!delete_removes_current_object(&ObjectOptions {
|
||||||
|
version_id: Some(Uuid::new_v4().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compressed_object_delete_restores_usage_baseline() {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
|
||||||
|
let object = ObjectInfo {
|
||||||
|
size: 400,
|
||||||
|
actual_size: 1000,
|
||||||
|
user_defined: Arc::new(metadata),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let accounting_size = quota_object_size(&object).expect("logical compressed size should be canonical");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
delete_memory_update(false, false, true, Some(accounting_size), true),
|
||||||
|
Some(DeleteMemoryUpdate::Object {
|
||||||
|
size: 1000,
|
||||||
|
removed_current_object: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_accounting_metadata_is_reconciled_without_overflow() {
|
||||||
|
assert_eq!(delete_memory_update(false, false, true, None, true), None);
|
||||||
|
assert_eq!(
|
||||||
|
delete_memory_update(false, true, true, None, true),
|
||||||
|
Some(DeleteMemoryUpdate::DeleteMarker)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn compressed_delete_requests_restore_usage_baseline() {
|
||||||
|
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||||
|
|
||||||
|
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||||
|
if current_app_context().is_none() {
|
||||||
|
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
||||||
|
}
|
||||||
|
let bucket = format!("compressed-delete-request-{}", Uuid::new_v4().simple());
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create compressed delete request bucket");
|
||||||
|
|
||||||
|
// Seed the process-local usage with the canonical logical bytes. The
|
||||||
|
// direct storage PUT below intentionally does not apply an app-layer
|
||||||
|
// usage delta; the two real DELETE requests must remove exactly this
|
||||||
|
// amount through their request-layer wiring.
|
||||||
|
crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 2_000).await;
|
||||||
|
|
||||||
|
for object in ["single", "batch"] {
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
insert_str(&mut metadata, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
|
||||||
|
insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, "1000".to_string());
|
||||||
|
let reader = HashReader::from_stream(std::io::Cursor::new(vec![0x5a; 400]), 400, 1000, None, None, false)
|
||||||
|
.expect("compressed fixture reader should be valid");
|
||||||
|
let mut reader = PutObjReader::new(reader);
|
||||||
|
store
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
user_defined: metadata,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("compressed fixture object should be written");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut single_req = build_request(
|
||||||
|
DeleteObjectInput::builder()
|
||||||
|
.bucket(bucket.clone())
|
||||||
|
.key("single".to_string())
|
||||||
|
.build()
|
||||||
|
.expect("single delete input should build"),
|
||||||
|
Method::DELETE,
|
||||||
|
);
|
||||||
|
single_req.extensions.insert(crate::storage::access::ReqInfo {
|
||||||
|
cred: Some(rustfs_credentials::Credentials::default()),
|
||||||
|
is_owner: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
DefaultObjectUsecase::from_global()
|
||||||
|
.execute_delete_object(single_req)
|
||||||
|
.await
|
||||||
|
.expect("single compressed delete should succeed");
|
||||||
|
assert_eq!(
|
||||||
|
crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await,
|
||||||
|
Some(1_000),
|
||||||
|
"single delete must subtract the logical accounting size"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut batch_req = build_request(
|
||||||
|
DeleteObjectsInput::builder()
|
||||||
|
.bucket(bucket.clone())
|
||||||
|
.delete(Delete {
|
||||||
|
objects: vec![ObjectIdentifier {
|
||||||
|
key: "batch".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
quiet: None,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.expect("batch delete input should build"),
|
||||||
|
Method::POST,
|
||||||
|
);
|
||||||
|
batch_req.extensions.insert(crate::storage::access::ReqInfo {
|
||||||
|
cred: Some(rustfs_credentials::Credentials::default()),
|
||||||
|
is_owner: true,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
DefaultObjectUsecase::from_global()
|
||||||
|
.execute_delete_objects(batch_req)
|
||||||
|
.await
|
||||||
|
.expect("batch compressed delete should succeed");
|
||||||
|
assert_eq!(
|
||||||
|
crate::app::storage_api::test::data_usage::get_bucket_usage_memory(&bucket).await,
|
||||||
|
Some(0),
|
||||||
|
"batch delete must subtract the committed logical accounting size"
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.delete_bucket(
|
||||||
|
&bucket,
|
||||||
|
&DeleteBucketOptions {
|
||||||
|
force: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("clean up compressed delete request bucket");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() {
|
async fn execute_get_object_attributes_returns_internal_error_when_store_uninitialized() {
|
||||||
let input = GetObjectAttributesInput::builder()
|
let input = GetObjectAttributesInput::builder()
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ pub(crate) mod data_usage {
|
|||||||
compute_bucket_usage, live_bucket_usage_computations, seed_bucket_usage_memory_for_test, store_data_usage_in_backend,
|
compute_bucket_usage, live_bucket_usage_computations, seed_bucket_usage_memory_for_test, store_data_usage_in_backend,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn get_bucket_usage_memory(bucket: &str) -> Option<u64> {
|
||||||
|
crate::storage::storage_api::ecstore_data_usage::get_bucket_usage_memory(bucket).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) {
|
pub(crate) async fn record_bucket_object_delete_memory(bucket: &str, deleted_size: u64, removed_current_object: bool) {
|
||||||
crate::storage::storage_api::ecstore_data_usage::record_bucket_object_delete_memory(
|
crate::storage::storage_api::ecstore_data_usage::record_bucket_object_delete_memory(
|
||||||
bucket,
|
bucket,
|
||||||
@@ -1233,7 +1238,10 @@ pub(crate) mod test {
|
|||||||
|
|
||||||
pub(crate) use super::access::ReqInfo;
|
pub(crate) use super::access::ReqInfo;
|
||||||
pub(crate) use super::options::VERSIONING_CONFIG_LOOKUPS;
|
pub(crate) use super::options::VERSIONING_CONFIG_LOOKUPS;
|
||||||
pub(crate) use super::{bucket, data_usage, ecfs, object_utils, runtime};
|
pub(crate) use super::{bucket, ecfs, object_utils, runtime};
|
||||||
|
pub(crate) mod data_usage {
|
||||||
|
pub(crate) use super::super::data_usage::*;
|
||||||
|
}
|
||||||
pub(crate) use crate::storage::storage_api::test_consumer::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
pub(crate) use crate::storage::storage_api::test_consumer::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||||
pub(crate) use crate::storage::storage_api::{
|
pub(crate) use crate::storage::storage_api::{
|
||||||
ECStore, Endpoint, Endpoints, PoolEndpoints, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader,
|
ECStore, Endpoint, Endpoints, PoolEndpoints, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader,
|
||||||
|
|||||||
@@ -296,7 +296,10 @@ pub(crate) fn build_list_objects_v2_output(
|
|||||||
let mut obj = Object {
|
let mut obj = Object {
|
||||||
key: Some(key),
|
key: Some(key),
|
||||||
last_modified: v.mod_time.map(Timestamp::from),
|
last_modified: v.mod_time.map(Timestamp::from),
|
||||||
size: Some(v.get_actual_size().unwrap_or_default()),
|
// Compressed legacy objects may retain an unknown (-1)
|
||||||
|
// logical-size sentinel; never expose that internal value in
|
||||||
|
// an S3 response.
|
||||||
|
size: Some(v.get_actual_size_or_physical()),
|
||||||
e_tag: v.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
e_tag: v.etag.clone().map(|etag| to_s3s_etag(&etag)),
|
||||||
storage_class: v.storage_class.clone().map(ObjectStorageClass::from),
|
storage_class: v.storage_class.clone().map(ObjectStorageClass::from),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -656,6 +659,45 @@ mod tests {
|
|||||||
assert_eq!(output.common_prefixes.as_ref().map(std::vec::Vec::len), Some(2));
|
assert_eq!(output.common_prefixes.as_ref().map(std::vec::Vec::len), Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_objects_never_exposes_compressed_unknown_size_sentinel() {
|
||||||
|
let mut metadata = std::collections::HashMap::new();
|
||||||
|
rustfs_utils::http::insert_str(
|
||||||
|
&mut metadata,
|
||||||
|
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||||
|
"klauspost/compress/s2".to_string(),
|
||||||
|
);
|
||||||
|
let output = build_list_objects_v2_output(
|
||||||
|
ListObjectsV2Info {
|
||||||
|
objects: vec![ObjectInfo {
|
||||||
|
name: "legacy-compressed".to_string(),
|
||||||
|
size: 128,
|
||||||
|
actual_size: -1,
|
||||||
|
user_defined: std::sync::Arc::new(metadata),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
1000,
|
||||||
|
"bucket".to_string(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
output
|
||||||
|
.contents
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|objects| objects.first())
|
||||||
|
.and_then(|object| object.size),
|
||||||
|
Some(128)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_responses_report_standard_for_legacy_label_only_file_metadata() {
|
fn list_responses_report_standard_for_legacy_label_only_file_metadata() {
|
||||||
let version_id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").expect("fixture version ID should be valid");
|
let version_id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").expect("fixture version ID should be valid");
|
||||||
|
|||||||
@@ -429,6 +429,8 @@ pub(crate) mod ecstore_config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) mod ecstore_data_usage {
|
pub(crate) mod ecstore_data_usage {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use rustfs_ecstore::api::data_usage::get_bucket_usage_memory;
|
||||||
pub(crate) use rustfs_ecstore::api::data_usage::{
|
pub(crate) use rustfs_ecstore::api::data_usage::{
|
||||||
apply_bucket_usage_memory_overlay, init_compression_total_memory_from_backend, load_admin_data_usage_from_backend_cached,
|
apply_bucket_usage_memory_overlay, init_compression_total_memory_from_backend, load_admin_data_usage_from_backend_cached,
|
||||||
load_data_usage_from_backend, quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory,
|
load_data_usage_from_backend, quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory,
|
||||||
|
|||||||
Reference in New Issue
Block a user