fix(lifecycle): make tier cleanup recoverable (#3491)

This commit is contained in:
cxymds
2026-06-16 08:45:49 +08:00
committed by GitHub
parent d23ed02b3f
commit 156e21c90e
10 changed files with 1357 additions and 83 deletions
@@ -17,8 +17,9 @@ use crate::bucket::lifecycle::evaluator::Evaluator;
use crate::bucket::lifecycle::lifecycle::{
self, ExpirationOptions, Lifecycle, ObjectOpts, TransitionOptions, abort_incomplete_multipart_upload_due,
};
use crate::bucket::lifecycle::tier_free_version_recovery::{DEFAULT_FREE_VERSION_RECOVERY_LIMIT, recover_tier_free_versions};
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier};
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent};
use crate::bucket::object_lock::objectlock_sys::check_object_lock_for_deletion;
use crate::bucket::replication::{
DeletedObjectReplicationInfo, ReplicationConfig, check_replicate_delete, schedule_replication_delete,
@@ -71,7 +72,7 @@ use std::collections::{HashMap, HashSet};
use std::env;
use std::pin::Pin;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::Duration as StdDuration;
use time::OffsetDateTime;
use tokio::select;
@@ -99,6 +100,7 @@ pub type TraceFn =
pub type ExpiryOpType = Box<dyn ExpiryOp + Send + Sync + 'static>;
static XXHASH_SEED: u64 = 0;
static TIER_FREE_VERSION_RECOVERY_STARTED: OnceLock<()> = OnceLock::new();
pub const AMZ_OBJECT_TAGGING: &str = "X-Amz-Tagging";
pub const AMZ_TAG_COUNT: &str = "x-amz-tagging-count";
@@ -441,6 +443,23 @@ impl ExpiryState {
}))
}
#[cfg(test)]
fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
let (tx, rx) = mpsc::channel(capacity);
Arc::new(RwLock::new(Self {
tasks_tx: vec![tx],
tasks_rx: vec![Arc::new(tokio::sync::Mutex::new(rx))],
stats: Arc::new(ExpiryStats {
missed_expiry_tasks: AtomicI64::new(0),
missed_freevers_tasks: AtomicI64::new(0),
missed_tier_journal_tasks: AtomicI64::new(0),
pending_tasks: AtomicI64::new(0),
active_tasks: AtomicI64::new(0),
workers: AtomicI64::new(1),
}),
}))
}
pub fn pending_tasks(&self) -> usize {
usize::try_from(self.stats.pending_tasks().max(0)).unwrap_or(usize::MAX)
}
@@ -470,13 +489,13 @@ impl ExpiryState {
Ok(())
}
pub async fn enqueue_free_version(&mut self, oi: ObjectInfo) {
pub async fn enqueue_free_version(&mut self, oi: ObjectInfo) -> bool {
let task = FreeVersionTask(oi);
let wrkr = self.get_worker_ch(task.op_hash());
if wrkr.is_none() {
self.stats.increment_missed_freevers_tasks();
self.stats.record_scanner_expiry_state();
return;
return false;
}
let wrkr = wrkr.expect("worker channel should exist after None check");
let queued = self.send_expiry_task(wrkr, Box::new(task)).await;
@@ -484,6 +503,7 @@ impl ExpiryState {
self.stats.increment_missed_freevers_tasks();
}
self.stats.record_scanner_expiry_state();
queued
}
pub async fn enqueue_by_days(&mut self, oi: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) -> bool {
@@ -637,7 +657,7 @@ impl ExpiryState {
}
else if v.as_any().is::<Jentry>() {
let v = v.as_any().downcast_ref::<Jentry>().expect("Jentry downcast failed");
if let Err(err) = delete_object_from_remote_tier(&v.obj_name, &v.version_id, &v.tier_name).await {
if let Err(err) = delete_object_from_remote_tier_idempotent(&v.obj_name, &v.version_id, &v.tier_name).await {
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
@@ -654,7 +674,7 @@ impl ExpiryState {
else if v.as_any().is::<FreeVersionTask>() {
let v = v.as_any().downcast_ref::<FreeVersionTask>().expect("FreeVersionTask downcast failed");
let oi = v.0.clone();
if let Err(err) = delete_object_from_remote_tier(
if let Err(err) = delete_object_from_remote_tier_idempotent(
&oi.transitioned_object.name,
&oi.transitioned_object.version_id,
&oi.transitioned_object.tier,
@@ -744,6 +764,33 @@ impl ExpiryState {
}
}
async fn enqueue_recovered_free_version_with_state(state: &Arc<RwLock<ExpiryState>>, oi: ObjectInfo) -> bool {
let task = FreeVersionTask(oi);
let hash = task.op_hash();
let (wrkr, stats) = {
let state = state.read().await;
(state.get_worker_ch(hash), Arc::clone(&state.stats))
};
let Some(wrkr) = wrkr else {
stats.increment_missed_freevers_tasks();
stats.record_scanner_expiry_state();
return false;
};
let queued = wrkr.try_send(Some(Box::new(task))).is_ok();
if !queued {
stats.increment_missed_freevers_tasks();
} else {
stats.increment_pending_tasks();
}
stats.record_scanner_expiry_state();
queued
}
pub async fn enqueue_recovered_free_version(oi: ObjectInfo) -> bool {
enqueue_recovered_free_version_with_state(&GLOBAL_ExpiryState, oi).await
}
struct TransitionTask {
obj_info: ObjectInfo,
src: LcEventSrc,
@@ -1303,7 +1350,78 @@ pub async fn init_background_expiry(api: Arc<ECStore>) {
}
//let expiry_state = GLOBAL_ExpiryStSate.write().await;
ExpiryState::resize_workers(workers, api).await;
ExpiryState::resize_workers(workers, api.clone()).await;
spawn_tier_free_version_recovery_once(api);
}
fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>) {
if TIER_FREE_VERSION_RECOVERY_STARTED.set(()).is_err() {
return;
}
tokio::spawn(async move {
let cancel_token = crate::global::get_background_services_cancel_token()
.cloned()
.unwrap_or_else(CancellationToken::new);
let mut interval = tokio::time::interval(StdDuration::from_secs(60));
let mut bucket_marker: Option<String> = None;
let mut object_marker: Option<String> = None;
loop {
select! {
_ = cancel_token.cancelled() => return,
_ = interval.tick() => {}
}
let started_at = std::time::Instant::now();
match recover_tier_free_versions(
api.clone(),
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
bucket_marker.clone(),
object_marker.clone(),
)
.await
{
Ok(stats) => {
bucket_marker = stats.next_bucket_marker;
object_marker = stats.next_object_marker;
let (pending_tasks, active_tasks) = {
let state = GLOBAL_ExpiryState.read().await;
(state.pending_tasks(), state.stats.active_tasks())
};
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
duration_ms = started_at.elapsed().as_millis(),
scanned = stats.scanned,
scanned_entries = stats.scanned_entries,
buckets_scanned = stats.buckets_scanned,
enqueued = stats.enqueued,
failed = stats.failed,
truncated = stats.truncated,
next_bucket_marker = ?bucket_marker,
next_object_marker = ?object_marker,
pending_tasks,
active_tasks,
"Recovered tier free-version cleanup tasks"
);
}
Err(err) => {
warn!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
duration_ms = started_at.elapsed().as_millis(),
next_bucket_marker = ?bucket_marker,
next_object_marker = ?object_marker,
error = ?err,
"Failed to recover tier free-version cleanup tasks"
);
}
}
}
});
}
#[derive(Debug, Clone)]
@@ -1751,6 +1869,17 @@ fn mark_delete_opts_skip_decommissioned_on_remote_success(opts: &mut ObjectOptio
}
}
fn transitioned_cleanup_tuple(oi: &ObjectInfo) -> Result<(&str, &str, &str), std::io::Error> {
let transitioned = &oi.transitioned_object;
if transitioned.status != lifecycle::TRANSITION_COMPLETE {
return Err(std::io::Error::other("transitioned object cleanup tuple is not complete"));
}
if transitioned.name.is_empty() || transitioned.version_id.is_empty() || transitioned.tier.is_empty() {
return Err(std::io::Error::other("transitioned object cleanup tuple is incomplete"));
}
Ok((&transitioned.name, &transitioned.version_id, &transitioned.tier))
}
pub async fn enqueue_transition_immediate(oi: &ObjectInfo, src: LcEventSrc) {
if let Some(lc) = GLOBAL_LifecycleSys.get(&oi.bucket).await {
enqueue_transition_with_lifecycle(oi, &lc, &src).await;
@@ -1984,6 +2113,7 @@ pub async fn expire_transitioned_object(
//let traceFn = GLOBAL_LifecycleSys.trace(oi);
let mut opts = ObjectOptions {
versioned: BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
version_suspended: BucketVersioningSys::prefix_suspended(&oi.bucket, &oi.name).await,
expiration: ExpirationOptions { expire: true },
..Default::default()
};
@@ -2002,29 +2132,12 @@ pub async fn expire_transitioned_object(
};
}
let ret = delete_object_from_remote_tier(
&oi.transitioned_object.name,
&oi.transitioned_object.version_id,
&oi.transitioned_object.tier,
)
.await;
if let Err(e) = &ret {
error!(
event = EVENT_LIFECYCLE_TIER_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
tier = %oi.transitioned_object.tier,
tier_object = %oi.transitioned_object.name,
tier_version_id = %oi.transitioned_object.version_id,
operation = "delete_remote_transitioned_object",
error = ?e,
"Lifecycle tier operation failed"
);
}
mark_delete_opts_skip_decommissioned_on_remote_success(&mut opts, ret.is_ok());
let (_remote_object, _remote_version, _tier) = transitioned_cleanup_tuple(oi)?;
// Delete local metadata first so concurrent GET cannot observe metadata
// pointing to a remote tier version that has already been removed. If this
// only creates a delete marker, remote cleanup must be driven by persisted
// free-version recovery rather than the visible delete result.
let dobj = match api.delete_object(&oi.bucket, &oi.name, opts).await {
Ok(obj) => obj,
Err(e) => {
@@ -2038,8 +2151,7 @@ pub async fn expire_transitioned_object(
error = ?e,
"Lifecycle delete failed"
);
// Return the original object info if deletion fails
oi.clone()
return Err(std::io::Error::other(e));
}
};
@@ -2716,14 +2828,16 @@ mod tests {
use super::{
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
DEFAULT_TRANSITION_WORKERS_CAP, ExpiryState, GLOBAL_TransitionState, StaleMultipartUploadCandidate, TransitionState,
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at, lifecycle_deleted_object,
lifecycle_rule_has_date_expiration, lifecycle_version_purge_state_from_completed_targets,
mark_delete_opts_skip_decommissioned_on_remote_success, merge_stale_multipart_candidate, replication_state_for_delete,
resolve_transition_queue_capacity, resolve_transition_queue_send_timeout, resolve_transition_worker_count,
resolve_transition_workers_absolute_max, should_defer_date_expiry_for_recent_config_update,
should_reuse_lifecycle_delete_replication_state,
TransitionedObject, cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
enqueue_recovered_free_version_with_state, lifecycle_deleted_object, lifecycle_rule_has_date_expiration,
lifecycle_version_purge_state_from_completed_targets, mark_delete_opts_skip_decommissioned_on_remote_success,
merge_stale_multipart_candidate, replication_state_for_delete, resolve_transition_queue_capacity,
resolve_transition_queue_send_timeout, resolve_transition_worker_count, resolve_transition_workers_absolute_max,
should_defer_date_expiry_for_recent_config_update, should_reuse_lifecycle_delete_replication_state,
transitioned_cleanup_tuple,
};
use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
use crate::bucket::lifecycle::core::ExpirationOptions;
use crate::bucket::metadata::BUCKET_LIFECYCLE_CONFIG;
use crate::bucket::metadata_sys;
use crate::disk::RUSTFS_META_MULTIPART_BUCKET;
@@ -2779,6 +2893,104 @@ mod tests {
assert_eq!(expiry.queue_missed, 1);
}
#[tokio::test]
async fn enqueue_free_version_reports_false_without_worker_channel() {
let state = ExpiryState::new();
let mut state = state.write().await;
let oi = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
transitioned_object: TransitionedObject {
name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier: "WARM".to_string(),
free_version: true,
..Default::default()
},
..Default::default()
};
let queued = state.enqueue_free_version(oi).await;
assert!(!queued);
assert_eq!(state.stats.missed_free_vers_tasks(), 1);
}
#[tokio::test]
async fn enqueue_recovered_free_version_reports_false_without_worker_channel() {
let state = ExpiryState::new();
let oi = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
transitioned_object: TransitionedObject {
name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier: "WARM".to_string(),
free_version: true,
..Default::default()
},
..Default::default()
};
let queued = enqueue_recovered_free_version_with_state(&state, oi).await;
let state = state.read().await;
assert!(!queued);
assert_eq!(state.stats.missed_free_vers_tasks(), 1);
}
#[tokio::test]
async fn enqueue_recovered_free_version_reports_false_when_worker_queue_full() {
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
let oi = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
transitioned_object: TransitionedObject {
name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier: "WARM".to_string(),
free_version: true,
..Default::default()
},
..Default::default()
};
let first = enqueue_recovered_free_version_with_state(&state, oi.clone()).await;
let second = enqueue_recovered_free_version_with_state(&state, oi).await;
let state = state.read().await;
assert!(first);
assert!(!second);
assert_eq!(state.stats.pending_tasks(), 1);
assert_eq!(state.stats.missed_free_vers_tasks(), 1);
}
#[tokio::test]
async fn lifecycle_free_version_recovery_enqueue_reports_retryable_queue_failure() {
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
let oi = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
transitioned_object: TransitionedObject {
name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier: "WARM".to_string(),
free_version: true,
..Default::default()
},
..Default::default()
};
let first = enqueue_recovered_free_version_with_state(&state, oi.clone()).await;
let second = enqueue_recovered_free_version_with_state(&state, oi).await;
let state = state.read().await;
assert!(first);
assert!(!second);
assert_eq!(state.stats.pending_tasks(), 1);
assert_eq!(state.stats.missed_free_vers_tasks(), 1);
}
#[tokio::test]
#[serial]
async fn scanner_transition_enqueue_reports_full_queue() {
@@ -2839,6 +3051,80 @@ mod tests {
assert!(opts.skip_decommissioned);
}
#[test]
fn transitioned_expiry_must_not_skip_free_version_before_remote_cleanup() {
let mut opts = ObjectOptions::default();
mark_delete_opts_skip_decommissioned_on_remote_success(&mut opts, false);
assert!(!opts.skip_decommissioned);
assert!(!opts.skip_free_version);
}
#[test]
fn transitioned_cleanup_tuple_requires_remote_name_version_and_tier() {
let mut oi = ObjectInfo::default();
oi.transitioned_object.status = crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE.to_string();
oi.transitioned_object.name = "remote/object".to_string();
oi.transitioned_object.version_id = "remote-version".to_string();
oi.transitioned_object.tier = "WARM".to_string();
let tuple = transitioned_cleanup_tuple(&oi).expect("complete tuple should be accepted");
assert_eq!(tuple, ("remote/object", "remote-version", "WARM"));
}
#[test]
fn transitioned_cleanup_tuple_rejects_missing_remote_version() {
let mut oi = ObjectInfo::default();
oi.transitioned_object.status = crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE.to_string();
oi.transitioned_object.name = "remote/object".to_string();
oi.transitioned_object.tier = "WARM".to_string();
let err = transitioned_cleanup_tuple(&oi).expect_err("missing version must be rejected");
assert!(err.to_string().contains("cleanup tuple is incomplete"));
}
#[test]
fn transitioned_cleanup_tuple_rejects_non_complete_status() {
let mut oi = ObjectInfo::default();
oi.transitioned_object.name = "remote/object".to_string();
oi.transitioned_object.version_id = "remote-version".to_string();
oi.transitioned_object.tier = "WARM".to_string();
oi.transitioned_object.status = "pending".to_string();
let err = transitioned_cleanup_tuple(&oi).expect_err("non-complete transition must be rejected");
assert!(err.to_string().contains("not complete"));
}
#[test]
fn transitioned_expiry_sets_version_suspended_in_delete_options() {
let opts = ObjectOptions {
versioned: true,
version_suspended: true,
expiration: ExpirationOptions { expire: true },
..Default::default()
};
assert!(opts.versioned);
assert!(opts.version_suspended);
assert!(!opts.skip_free_version);
}
#[test]
fn delete_marker_result_must_not_drive_remote_cleanup() {
let dobj = ObjectInfo {
delete_marker: true,
transitioned_object: TransitionedObject::default(),
..Default::default()
};
assert!(dobj.delete_marker);
assert!(dobj.transitioned_object.name.is_empty());
}
// SAFETY: this helper is only used from `#[serial]` tests and those tests run under a
// single-thread runtime (`worker_threads = 1`), so no concurrent reader/writer can access
// process environment while `env::set_var`/`env::remove_var` is active.
@@ -18,5 +18,6 @@ pub mod core;
pub mod evaluator;
pub use self::core as lifecycle;
pub mod rule;
pub mod tier_free_version_recovery;
pub mod tier_last_day_stats;
pub mod tier_sweeper;
@@ -0,0 +1,469 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::Result;
use crate::store::ECStore;
use crate::store_api::{BucketOperations, BucketOptions, ListOperations, ObjectInfo, ObjectInfoOrErr, WalkOptions};
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FreeVersionRecoveryStats {
pub scanned: usize,
pub enqueued: usize,
pub failed: usize,
pub next_bucket_marker: Option<String>,
pub next_object_marker: Option<String>,
pub scanned_entries: usize,
pub buckets_scanned: usize,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct FreeVersionRecoveryPage {
pub items: Vec<ObjectInfo>,
pub next_bucket_marker: Option<String>,
pub next_object_marker: Option<String>,
pub scanned_entries: usize,
pub buckets_scanned: usize,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RecoveryCursor {
bucket: String,
object: String,
}
pub async fn recover_tier_free_versions(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
) -> Result<FreeVersionRecoveryStats> {
if limit == 0 {
return Err(std::io::Error::other("free-version recovery limit must be greater than zero").into());
}
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone()).await?;
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: page.next_bucket_marker,
next_object_marker: page.next_object_marker,
scanned_entries: page.scanned_entries,
buckets_scanned: page.buckets_scanned,
truncated: page.truncated,
};
let mut retry_cursor = RetryCursor::new(bucket_marker, object_marker);
for oi in page.items {
retry_cursor.visit(&oi);
if !record_recovered_free_version_enqueue(&mut stats, queue_recovered_free_version(oi).await) {
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
stats.truncated = true;
stats.next_bucket_marker = bucket_marker;
stats.next_object_marker = object_marker;
break;
}
}
Ok(stats)
}
fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, queued: bool) -> bool {
stats.scanned += 1;
if queued {
stats.enqueued += 1;
true
} else {
stats.failed += 1;
false
}
}
async fn queue_recovered_free_version(oi: ObjectInfo) -> bool {
crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version(oi).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RetryCursor {
input_bucket_marker: Option<String>,
input_object_marker: Option<String>,
current: Option<RecoveryCursor>,
completed: Option<RecoveryCursor>,
}
impl RetryCursor {
fn new(input_bucket_marker: Option<String>, input_object_marker: Option<String>) -> Self {
Self {
input_bucket_marker,
input_object_marker,
current: None,
completed: None,
}
}
fn visit(&mut self, oi: &ObjectInfo) {
let cursor = RecoveryCursor {
bucket: oi.bucket.clone(),
object: oi.name.clone(),
};
if self.current.as_ref() == Some(&cursor) {
return;
}
if let Some(previous) = self.current.replace(cursor) {
self.completed = Some(previous);
}
}
fn retry_markers(&self) -> (Option<String>, Option<String>) {
if let Some(completed) = &self.completed {
return (Some(completed.bucket.clone()), Some(completed.object.clone()));
}
if let Some(current) = &self.current {
if self.input_bucket_marker.as_deref() == Some(current.bucket.as_str()) {
return (Some(current.bucket.clone()), self.input_object_marker.clone());
}
return (Some(current.bucket.clone()), None);
}
(self.input_bucket_marker.clone(), self.input_object_marker.clone())
}
}
async fn list_tier_free_versions(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
) -> Result<FreeVersionRecoveryPage> {
let mut page = FreeVersionRecoveryPage {
items: Vec::new(),
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: 0,
buckets_scanned: 0,
truncated: false,
};
if limit == 0 {
return Ok(page);
}
let buckets = api.list_bucket(&BucketOptions::default()).await?;
let mut bucket_seen = bucket_marker.is_none();
let mut truncated_after: Option<RecoveryCursor> = None;
let walk_scan_limit = recovery_walk_scan_limit(limit);
for bucket in buckets {
if bucket.name == RUSTFS_META_BUCKET {
continue;
}
if !bucket_seen {
if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
bucket_seen = true;
} else {
continue;
}
}
page.buckets_scanned += 1;
let bucket_object_marker = if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
object_marker.clone()
} else {
None
};
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
let cancel = CancellationToken::new();
let mut draining_after_truncation = false;
let mut last_seen_object: Option<String> = None;
let mut scanned_objects = 0usize;
let walk = tokio::spawn({
let api = api.clone();
let bucket_name = bucket.name.clone();
let object_marker = bucket_object_marker.clone();
let cancel = cancel.clone();
async move {
api.walk(
cancel,
&bucket_name,
"",
tx,
WalkOptions {
include_free_versions: true,
limit: walk_scan_limit,
marker: object_marker,
..Default::default()
},
)
.await
}
});
while let Some(item) = rx.recv().await {
page.scanned_entries += 1;
if draining_after_truncation {
continue;
}
if let Some(err) = item.err {
cancel.cancel();
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
return Err(err);
}
let Some(oi) = item.item else {
continue;
};
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
if let Some(cursor) = &truncated_after
&& cursor.object != oi.name
{
page.truncated = true;
cancel.cancel();
draining_after_truncation = true;
continue;
}
if is_recoverable_tier_free_version(&oi) {
let cursor = RecoveryCursor {
bucket: bucket.name.clone(),
object: oi.name.clone(),
};
page.items.push(oi);
page.next_bucket_marker = Some(cursor.bucket.clone());
page.next_object_marker = Some(cursor.object.clone());
if page.items.len() >= limit && truncated_after.is_none() {
truncated_after = Some(cursor);
}
}
}
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
if page.truncated {
page.next_bucket_marker = Some(bucket.name.clone());
break;
}
}
if !page.truncated {
page.next_bucket_marker = None;
page.next_object_marker = None;
}
Ok(page)
}
fn recovery_walk_scan_limit(limit: usize) -> usize {
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT.max(limit.saturating_add(1))
}
fn record_scanned_object(last_seen_object: &mut Option<String>, scanned_objects: &mut usize, object: &str) {
if last_seen_object.as_deref() == Some(object) {
return;
}
*last_seen_object = Some(object.to_string());
*scanned_objects = scanned_objects.saturating_add(1);
}
fn mark_scan_truncated_if_needed(
page: &mut FreeVersionRecoveryPage,
scanned_objects: usize,
walk_scan_limit: usize,
bucket: &str,
last_seen_object: Option<&str>,
) {
if page.truncated || scanned_objects < walk_scan_limit {
return;
}
if let Some(last_seen_object) = last_seen_object {
page.truncated = true;
page.next_bucket_marker = Some(bucket.to_string());
page.next_object_marker = Some(last_seen_object.to_string());
}
}
fn is_recoverable_tier_free_version(oi: &ObjectInfo) -> bool {
oi.transitioned_object.free_version
&& !oi.transitioned_object.name.is_empty()
&& !oi.transitioned_object.version_id.is_empty()
&& !oi.transitioned_object.tier.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recovery_stats_count_failed_enqueue_without_treating_scan_as_lost() {
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: 1,
buckets_scanned: 1,
truncated: false,
};
record_recovered_free_version_enqueue(&mut stats, false);
assert_eq!(stats.scanned, 1);
assert_eq!(stats.enqueued, 0);
assert_eq!(stats.failed, 1);
}
#[test]
fn recovery_enqueue_failure_returns_false_so_cursor_can_retry_same_object() {
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: Some("bucket".to_string()),
next_object_marker: Some("object".to_string()),
scanned_entries: 1,
buckets_scanned: 1,
truncated: false,
};
let queued = record_recovered_free_version_enqueue(&mut stats, false);
assert!(!queued);
assert_eq!(stats.scanned, 1);
assert_eq!(stats.failed, 1);
}
#[test]
fn recover_tier_free_versions_failed_enqueue_retries_same_persisted_cursor() {
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
failed: 0,
next_bucket_marker: Some("bucket".to_string()),
next_object_marker: Some("object-a".to_string()),
scanned_entries: 1,
buckets_scanned: 1,
truncated: false,
};
let mut cursor = RetryCursor::new(stats.next_bucket_marker.clone(), stats.next_object_marker.clone());
let oi = object_info("bucket", "object-b");
cursor.visit(&oi);
let queued = record_recovered_free_version_enqueue(&mut stats, false);
let (bucket_marker, object_marker) = cursor.retry_markers();
assert!(!queued);
assert_eq!(bucket_marker, Some("bucket".to_string()));
assert_eq!(object_marker, Some("object-a".to_string()));
assert_eq!(stats.failed, 1);
}
#[test]
fn retry_cursor_rewinds_to_previous_object_on_enqueue_failure() {
let mut cursor = RetryCursor::new(Some("bucket".to_string()), Some("object-a".to_string()));
cursor.visit(&object_info("bucket", "object-b"));
cursor.visit(&object_info("bucket", "object-b"));
assert_eq!(cursor.retry_markers(), (Some("bucket".to_string()), Some("object-a".to_string())));
cursor.visit(&object_info("bucket", "object-c"));
assert_eq!(cursor.retry_markers(), (Some("bucket".to_string()), Some("object-b".to_string())));
}
#[test]
fn retry_cursor_rewinds_to_bucket_start_when_first_bucket_object_fails() {
let mut cursor = RetryCursor::new(None, None);
cursor.visit(&object_info("bucket", "object-a"));
assert_eq!(cursor.retry_markers(), (Some("bucket".to_string()), None));
}
#[test]
fn recoverable_tier_free_version_requires_complete_remote_tuple() {
let mut oi = ObjectInfo::default();
oi.transitioned_object.free_version = true;
oi.transitioned_object.name = "remote/object".to_string();
oi.transitioned_object.version_id = "remote-version".to_string();
oi.transitioned_object.tier = "WARM".to_string();
assert!(is_recoverable_tier_free_version(&oi));
oi.transitioned_object.version_id.clear();
assert!(!is_recoverable_tier_free_version(&oi));
}
#[test]
fn recovery_scan_limit_is_independent_from_enqueue_limit() {
assert_eq!(recovery_walk_scan_limit(1), DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT);
assert_eq!(
recovery_walk_scan_limit(DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT),
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT + 1
);
}
#[test]
fn scan_truncation_keeps_marker_after_nonrecoverable_window() {
let mut page = FreeVersionRecoveryPage {
items: Vec::new(),
next_bucket_marker: None,
next_object_marker: None,
scanned_entries: DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
buckets_scanned: 1,
truncated: false,
};
mark_scan_truncated_if_needed(
&mut page,
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
"bucket",
Some("object-z"),
);
assert!(page.truncated);
assert_eq!(page.next_bucket_marker, Some("bucket".to_string()));
assert_eq!(page.next_object_marker, Some("object-z".to_string()));
}
#[test]
fn scanned_object_count_advances_only_on_new_objects() {
let mut last_seen = None;
let mut scanned_objects = 0;
record_scanned_object(&mut last_seen, &mut scanned_objects, "object-a");
record_scanned_object(&mut last_seen, &mut scanned_objects, "object-a");
record_scanned_object(&mut last_seen, &mut scanned_objects, "object-b");
assert_eq!(scanned_objects, 2);
assert_eq!(last_seen, Some("object-b".to_string()));
}
fn object_info(bucket: &str, object: &str) -> ObjectInfo {
ObjectInfo {
bucket: bucket.to_string(),
name: object.to_string(),
..Default::default()
}
}
}
@@ -45,6 +45,8 @@ const DEFAULT_REMOTE_DELETE_BREAKER_WINDOW_SECS: usize = 30;
const METRIC_DELETE_REMOTE_FAILED_TOTAL: &str = "rustfs_delete_remote_failed_total";
const METRIC_DELETE_REMOTE_BREAKER_TOTAL: &str = "rustfs_delete_remote_breaker_total";
const METRIC_DELETE_REMOTE_INFLIGHT: &str = "rustfs_delete_remote_inflight";
const ERR_REMOTE_DELETE_BREAKER_OPEN: &str = "remote tier delete breaker is open due to signer/header failures";
const ERR_REMOTE_DELETE_LIMITER_CLOSED: &str = "remote tier delete limiter is closed";
static REMOTE_DELETE_INFLIGHT: AtomicUsize = AtomicUsize::new(0);
@@ -63,6 +65,11 @@ static REMOTE_DELETE_BREAKER: LazyLock<Mutex<RemoteDeleteBreaker>> = LazyLock::n
))
});
#[cfg(test)]
static REMOTE_TIER_DELETE_TEST_HOOK: std::sync::LazyLock<
std::sync::Mutex<Option<Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
#[derive(Debug)]
struct RemoteDeleteBreaker {
threshold: usize,
@@ -158,6 +165,11 @@ async fn record_remote_delete_failure(err: &std::io::Error, now: Instant) {
}
}
fn should_record_remote_delete_failure(err: &std::io::Error) -> bool {
let message = err.to_string();
message != ERR_REMOTE_DELETE_BREAKER_OPEN && message != ERR_REMOTE_DELETE_LIMITER_CLOSED
}
#[derive(Default)]
#[allow(dead_code)]
struct ObjSweeper {
@@ -277,31 +289,78 @@ impl ExpiryOp for Jentry {
}
pub async fn delete_object_from_remote_tier(obj_name: &str, rv_id: &str, tier_name: &str) -> Result<(), std::io::Error> {
let result = delete_object_from_remote_tier_raw(obj_name, rv_id, tier_name).await;
if let Err(err) = &result
&& should_record_remote_delete_failure(err)
{
record_remote_delete_failure(err, Instant::now()).await;
}
result
}
async fn delete_object_from_remote_tier_raw(obj_name: &str, rv_id: &str, tier_name: &str) -> Result<(), std::io::Error> {
#[cfg(test)]
if let Some(result) = run_remote_tier_delete_test_hook(obj_name, rv_id, tier_name) {
return result;
}
if remote_delete_breaker_is_open(Instant::now()).await {
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
return Err(std::io::Error::other("remote tier delete breaker is open due to signer/header failures"));
return Err(std::io::Error::other(ERR_REMOTE_DELETE_BREAKER_OPEN));
}
let _permit = REMOTE_DELETE_LIMITER
.acquire()
.await
.map_err(|_| std::io::Error::other("remote tier delete limiter is closed"))?;
.map_err(|_| std::io::Error::other(ERR_REMOTE_DELETE_LIMITER_CLOSED))?;
let _inflight = RemoteDeleteInflightGuard::new();
let mut config_mgr = GLOBAL_TierConfigMgr.write().await;
let w = match config_mgr.get_driver(tier_name).await {
Ok(w) => w,
Err(e) => {
let err = std::io::Error::other(e);
record_remote_delete_failure(&err, Instant::now()).await;
return Err(err);
}
Err(e) => return Err(std::io::Error::other(e)),
};
let result = w.remove(obj_name, rv_id).await;
if let Err(err) = &result {
record_remote_delete_failure(err, Instant::now()).await;
w.remove(obj_name, rv_id).await
}
#[cfg(test)]
fn run_remote_tier_delete_test_hook(obj_name: &str, rv_id: &str, tier_name: &str) -> Option<std::io::Result<()>> {
REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison")
.as_ref()
.map(|hook| hook(obj_name, rv_id, tier_name))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteTierDeleteOutcome {
Deleted,
AlreadyRemoved,
}
pub async fn delete_object_from_remote_tier_idempotent(
obj_name: &str,
rv_id: &str,
tier_name: &str,
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
match delete_object_from_remote_tier_raw(obj_name, rv_id, tier_name).await {
Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted),
Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved),
Err(err) => {
if should_record_remote_delete_failure(&err) {
record_remote_delete_failure(&err, Instant::now()).await;
}
Err(err)
}
}
result
}
pub(crate) fn is_remote_tier_not_found_error(err: &std::io::Error) -> bool {
let message = err.to_string();
message.contains("NoSuchKey")
|| message.contains("NoSuchVersion")
|| message.contains("ObjectNotFound")
|| message.contains("VersionNotFound")
}
pub fn transitioned_delete_journal_entry(
@@ -340,10 +399,35 @@ pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject
mod test {
use crate::client::signer_error::invalid_utf8_header_error;
use super::{RemoteDeleteBreaker, is_signer_header_error};
use super::{
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, REMOTE_TIER_DELETE_TEST_HOOK, RemoteDeleteBreaker,
RemoteTierDeleteOutcome, delete_object_from_remote_tier_idempotent, is_remote_tier_not_found_error,
is_signer_header_error, should_record_remote_delete_failure,
};
use std::io::{Error, ErrorKind};
use std::time::{Duration, Instant};
struct RemoteTierDeleteHookGuard;
impl Drop for RemoteTierDeleteHookGuard {
fn drop(&mut self) {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = None;
}
}
fn set_remote_tier_delete_test_hook(
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
) -> RemoteTierDeleteHookGuard {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RemoteTierDeleteHookGuard
}
#[test]
fn signer_header_error_detection_matches_utf8_failures() {
let err = Error::new(
@@ -365,6 +449,54 @@ mod test {
assert!(is_signer_header_error(&err));
}
#[test]
fn remote_tier_not_found_errors_are_idempotent_success() {
assert!(is_remote_tier_not_found_error(&Error::other("NoSuchVersion")));
assert!(is_remote_tier_not_found_error(&Error::other("NoSuchKey")));
assert!(is_remote_tier_not_found_error(&Error::other("ObjectNotFound")));
assert!(is_remote_tier_not_found_error(&Error::other("VersionNotFound")));
assert!(!is_remote_tier_not_found_error(&Error::other("timeout")));
assert!(!is_remote_tier_not_found_error(&Error::other("tier config not found")));
assert!(!is_remote_tier_not_found_error(&Error::other("driver not found")));
}
#[test]
fn remote_tier_control_plane_short_circuit_errors_do_not_count_as_delete_failures() {
assert!(!should_record_remote_delete_failure(&Error::other(ERR_REMOTE_DELETE_BREAKER_OPEN)));
assert!(!should_record_remote_delete_failure(&Error::other(ERR_REMOTE_DELETE_LIMITER_CLOSED)));
assert!(should_record_remote_delete_failure(&Error::other("driver not found")));
assert!(should_record_remote_delete_failure(&Error::other("NoSuchVersion")));
}
#[tokio::test]
#[serial_test::serial]
async fn idempotent_remote_delete_treats_hooked_nosuchversion_as_already_removed() {
let _hook = set_remote_tier_delete_test_hook(|obj_name, rv_id, tier_name| {
assert_eq!(obj_name, "remote/object");
assert_eq!(rv_id, "remote-version");
assert_eq!(tier_name, "WARM");
Err(Error::other("NoSuchVersion"))
});
let outcome = delete_object_from_remote_tier_idempotent("remote/object", "remote-version", "WARM")
.await
.expect("remote not-found should be idempotent success");
assert_eq!(outcome, RemoteTierDeleteOutcome::AlreadyRemoved);
}
#[tokio::test]
#[serial_test::serial]
async fn idempotent_remote_delete_preserves_driver_not_found_failures() {
let _hook = set_remote_tier_delete_test_hook(|_, _, _| Err(Error::other("driver not found")));
let err = delete_object_from_remote_tier_idempotent("remote/object", "remote-version", "WARM")
.await
.expect_err("driver lookup failure must not be idempotent success");
assert!(err.to_string().contains("driver not found"));
}
#[test]
fn breaker_opens_at_threshold_and_recovers_after_window() {
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
+96 -16
View File
@@ -160,6 +160,16 @@ fn is_decommission_active(complete: bool, failed: bool, canceled: bool) -> bool
!complete && !failed && !canceled
}
fn validate_decommission_terminal_state(complete: bool, failed: bool, canceled: bool) -> Result<()> {
let terminal_count = [complete, failed, canceled].into_iter().filter(|terminal| *terminal).count();
if terminal_count > 1 {
return Err(Error::other(format!(
"pool metadata load failed: invalid decommission terminal state complete={complete} failed={failed} canceled={canceled}"
)));
}
Ok(())
}
fn invalid_decommission_pool_index_error(pool_count: usize, idx: usize) -> Error {
Error::other(format!("invalid decommission pool index {idx} for {pool_count} pools"))
}
@@ -532,30 +542,37 @@ struct PersistedPoolDecommissionInfo {
pub bytes_failed: usize,
}
impl From<PersistedPoolMeta> for PoolMeta {
fn from(value: PersistedPoolMeta) -> Self {
Self {
impl TryFrom<PersistedPoolMeta> for PoolMeta {
type Error = Error;
fn try_from(value: PersistedPoolMeta) -> Result<Self> {
Ok(Self {
version: value.version,
pools: value.pools.into_iter().map(Into::into).collect(),
pools: value.pools.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>>>()?,
dont_save: false,
}
})
}
}
impl From<PersistedPoolStatus> for PoolStatus {
fn from(value: PersistedPoolStatus) -> Self {
Self {
impl TryFrom<PersistedPoolStatus> for PoolStatus {
type Error = Error;
fn try_from(value: PersistedPoolStatus) -> Result<Self> {
Ok(Self {
id: value.id,
cmd_line: value.cmd_line,
last_update: value.last_update,
decommission: value.decommission.map(Into::into),
}
decommission: value.decommission.map(TryInto::try_into).transpose()?,
})
}
}
impl From<PersistedPoolDecommissionInfo> for PoolDecommissionInfo {
fn from(value: PersistedPoolDecommissionInfo) -> Self {
Self {
impl TryFrom<PersistedPoolDecommissionInfo> for PoolDecommissionInfo {
type Error = Error;
fn try_from(value: PersistedPoolDecommissionInfo) -> Result<Self> {
validate_decommission_terminal_state(value.complete, value.failed, value.canceled)?;
Ok(Self {
start_time: value.start_time,
start_size: value.start_size,
total_size: value.total_size,
@@ -572,7 +589,7 @@ impl From<PersistedPoolDecommissionInfo> for PoolDecommissionInfo {
items_decommission_failed: value.items_decommission_failed,
bytes_done: value.bytes_done,
bytes_failed: value.bytes_failed,
}
})
}
}
@@ -622,7 +639,7 @@ impl From<&PoolDecommissionInfo> for PersistedPoolDecommissionInfo {
impl PoolMeta {
fn decode_pool_meta_payload(payload: &[u8]) -> Result<Self> {
match rmp_serde::from_slice::<PersistedPoolMeta>(payload) {
Ok(meta) => Ok(meta.into()),
Ok(meta) => meta.try_into(),
Err(persisted_err) => {
let mut legacy: PoolMeta = rmp_serde::from_slice(payload).map_err(|legacy_err| {
Error::other(format!(
@@ -631,6 +648,11 @@ impl PoolMeta {
})?;
// Runtime-only flag must not be restored from on-disk payload.
legacy.dont_save = false;
for pool in &legacy.pools {
if let Some(decommission) = &pool.decommission {
validate_decommission_terminal_state(decommission.complete, decommission.failed, decommission.canceled)?;
}
}
Ok(legacy)
}
}
@@ -2706,7 +2728,8 @@ mod tests {
let mut deserializer = Deserializer::new(Cursor::new(&buf));
let restored: PoolMeta = PersistedPoolMeta::deserialize(&mut deserializer)
.expect("pool meta should deserialize")
.into();
.try_into()
.expect("pool meta should validate");
let restored_decommission = restored.pools[0]
.decommission
@@ -2782,6 +2805,63 @@ mod tests {
assert!(decommission.prefix.is_empty());
assert!(decommission.object.is_empty());
}
#[test]
fn pool_meta_decode_rejects_invalid_decommission_terminal_state() {
let start_time = OffsetDateTime::now_utc();
let persisted_meta = PersistedPoolMeta {
version: POOL_META_VERSION,
pools: vec![PersistedPoolStatus {
id: 1,
cmd_line: "/data/pool1/disk{1...4}".to_string(),
last_update: start_time,
decommission: Some(PersistedPoolDecommissionInfo {
start_time: Some(start_time),
complete: true,
failed: true,
canceled: false,
..Default::default()
}),
}],
};
let mut payload = Vec::new();
persisted_meta
.serialize(&mut Serializer::new(&mut payload))
.expect("persisted payload should serialize");
let err = PoolMeta::decode_pool_meta_payload(&payload).expect_err("invalid terminal state should fail decode");
assert!(err.to_string().contains("invalid decommission terminal state"));
}
#[test]
fn pool_meta_decode_rejects_invalid_legacy_decommission_terminal_state() {
let start_time = OffsetDateTime::now_utc();
let legacy_meta = PoolMeta {
version: POOL_META_VERSION,
pools: vec![PoolStatus {
id: 1,
cmd_line: "/legacy/pool".to_string(),
last_update: start_time,
decommission: Some(PoolDecommissionInfo {
start_time: Some(start_time),
complete: true,
failed: false,
canceled: true,
..Default::default()
}),
}],
dont_save: true,
};
let mut payload = Vec::new();
legacy_meta
.serialize(&mut Serializer::new(&mut payload))
.expect("legacy payload should serialize");
let err = PoolMeta::decode_pool_meta_payload(&payload).expect_err("invalid legacy terminal state should fail decode");
assert!(err.to_string().contains("invalid decommission terminal state"));
}
}
// impl Fn(MetaCacheEntry) -> impl Future<Output = Result<(), Error>>
+31 -16
View File
@@ -51,7 +51,8 @@ use uuid::Uuid;
const REBAL_META_FMT: u16 = 1; // Replace with actual format value
const REBAL_META_VER: u16 = 1; // Replace with actual version value
const REBAL_META_NAME: &str = "rebalance.bin";
const REBALANCE_LISTING_MAX_ATTEMPTS: usize = 3;
const DEFAULT_REBALANCE_MAX_ATTEMPTS: usize = 3;
const REBALANCE_MAX_ATTEMPTS_ENV: &str = "RUSTFS_REBALANCE_MAX_ATTEMPTS";
const REBALANCE_LISTING_RETRY_BASE_DELAY: Duration = Duration::from_millis(250);
const REBALANCE_MIGRATION_RETRY_BASE_DELAY: Duration = Duration::from_millis(250);
const REBALANCE_MIGRATION_LOCK_RETRY_CAP: Duration = Duration::from_secs(10);
@@ -2056,6 +2057,17 @@ fn should_retry_rebalance_listing(err: &Error, attempt: usize, max_attempts: usi
attempt + 1 < max_attempts && is_transient_rebalance_error(err)
}
fn parse_rebalance_max_attempts(value: Option<&str>) -> usize {
value
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|attempts| *attempts > 0)
.unwrap_or(DEFAULT_REBALANCE_MAX_ATTEMPTS)
}
fn rebalance_max_attempts() -> usize {
parse_rebalance_max_attempts(std::env::var(REBALANCE_MAX_ATTEMPTS_ENV).ok().as_deref())
}
fn rebalance_listing_retry_delay(attempt: usize) -> Duration {
let multiplier = u32::try_from(attempt.saturating_add(1)).unwrap_or(u32::MAX);
REBALANCE_LISTING_RETRY_BASE_DELAY.saturating_mul(multiplier)
@@ -2612,7 +2624,7 @@ impl ECStore {
pool_index,
version,
version_id.clone(),
3,
rebalance_max_attempts(),
should_ignore_rebalance_data_usage_cache(bucket.as_str()),
&mut transfer,
)
@@ -2901,15 +2913,9 @@ impl ECStore {
let entry_tasks = entry_tasks.clone();
let job = tokio::spawn(async move {
let list_result = run_rebalance_listing_with_retry(
set,
rx,
bucket.clone(),
rebalance_entry,
set_idx,
REBALANCE_LISTING_MAX_ATTEMPTS,
)
.await;
let list_result =
run_rebalance_listing_with_retry(set, rx, bucket.clone(), rebalance_entry, set_idx, rebalance_max_attempts())
.await;
let entry_result = wait_rebalance_entry_tasks(set_idx, entry_tasks).await;
let result = list_result.and(entry_result);
if let Err(err) = &result {
@@ -3169,11 +3175,11 @@ mod rebalance_unit_tests {
ensure_rebalance_listing_disks_available, ensure_rebalance_not_decommissioning, ensure_valid_rebalance_pool_index,
has_deferred_rebalance_error, is_rebalance_stopped_terminal_event, is_transient_rebalance_error,
load_rebalance_bucket_configs, mark_rebalance_bucket_done, merge_rebalance_meta, migrate_entry_version,
migrate_entry_version_with_retry_wait, next_rebal_bucket_from_stat, rebalance_delete_marker_opts,
rebalance_listing_retry_delay, rebalance_meta_load_no_data_error, rebalance_meta_load_unknown_format_error,
rebalance_meta_load_unknown_version_error, rebalance_migration_retry_delay, record_rebalance_cleanup_warning_in_meta,
resolve_load_rebalance_stats_update_result, resolve_next_rebalance_bucket, resolve_rebalance_bucket_error,
resolve_rebalance_bucket_result, resolve_rebalance_entry_cleanup_delete_result,
migrate_entry_version_with_retry_wait, next_rebal_bucket_from_stat, parse_rebalance_max_attempts,
rebalance_delete_marker_opts, rebalance_listing_retry_delay, rebalance_meta_load_no_data_error,
rebalance_meta_load_unknown_format_error, rebalance_meta_load_unknown_version_error, rebalance_migration_retry_delay,
record_rebalance_cleanup_warning_in_meta, resolve_load_rebalance_stats_update_result, resolve_next_rebalance_bucket,
resolve_rebalance_bucket_error, resolve_rebalance_bucket_result, resolve_rebalance_entry_cleanup_delete_result,
resolve_rebalance_file_info_versions_result, resolve_rebalance_meta_load_result, resolve_rebalance_meta_save_result,
resolve_rebalance_migrate_result_error, resolve_rebalance_optional_bucket_config_result, resolve_rebalance_participants,
resolve_rebalance_save_task_result, resolve_rebalance_stats_update_result, resolve_rebalance_terminal_error,
@@ -4776,6 +4782,15 @@ mod rebalance_unit_tests {
assert!(!should_retry_rebalance_listing(&Error::FileAccessDenied, 0, 3));
}
#[test]
fn test_parse_rebalance_max_attempts_uses_positive_override_or_default() {
assert_eq!(parse_rebalance_max_attempts(Some("5")), 5);
assert_eq!(parse_rebalance_max_attempts(Some(" 7 ")), 7);
assert_eq!(parse_rebalance_max_attempts(Some("0")), 3);
assert_eq!(parse_rebalance_max_attempts(Some("invalid")), 3);
assert_eq!(parse_rebalance_max_attempts(None), 3);
}
#[test]
fn test_rebalance_listing_retry_delay_scales_by_attempt() {
assert_eq!(rebalance_listing_retry_delay(0), Duration::from_millis(250));
+1
View File
@@ -1050,6 +1050,7 @@ pub struct WalkOptions {
pub ask_disks: String, // dictates how many disks are being listed
pub versions_sort: WalkVersionsSortOrder, // sort order for versions of the same object; default: Ascending order in ModTime
pub limit: usize, // maximum number of items, 0 means no limit
pub include_free_versions: bool, // include persisted tier free-version cleanup records
}
#[derive(Clone, Default, PartialEq, Eq)]
+35 -1
View File
@@ -1071,7 +1071,11 @@ impl ECStore {
continue;
}
let fvs = match entry.file_info_versions(&bucket_clone) {
let fvs = match if opts.include_free_versions {
entry.file_info_versions_with_free_versions(&bucket_clone)
} else {
entry.file_info_versions(&bucket_clone)
} {
Ok(res) => res,
Err(err) => {
let item = ObjectInfoOrErr {
@@ -1117,6 +1121,36 @@ impl ECStore {
}
}
}
if opts.include_free_versions {
for fi in fvs.free_versions.iter() {
if let Some(filter) = opts.filter {
if filter(fi) {
let item = ObjectInfoOrErr {
item: Some(ObjectInfo::from_file_info(fi, &bucket_clone, &fi.name, {
if let Some(v) = &vcf { v.versioned(&fi.name) } else { false }
})),
err: None,
};
if let Err(err) = result.send(item).await {
error!("walk result send err {:?}", err);
}
}
} else {
let item = ObjectInfoOrErr {
item: Some(ObjectInfo::from_file_info(fi, &bucket_clone, &fi.name, {
if let Some(v) = &vcf { v.versioned(&fi.name) } else { false }
})),
err: None,
};
if let Err(err) = result.send(item).await {
error!("walk result send err {:?}", err);
}
}
}
}
}
}
.instrument(tracing::Span::current()),
+212 -1
View File
@@ -191,6 +191,27 @@ impl MetaCacheEntry {
fm.into_file_info_versions(bucket, self.name.as_str(), false)
}
pub fn file_info_versions_with_free_versions(&self, bucket: &str) -> Result<FileInfoVersions> {
if self.is_dir() {
return Ok(FileInfoVersions {
volume: bucket.to_string(),
name: self.name.clone(),
versions: vec![FileInfo {
volume: bucket.to_string(),
name: self.name.clone(),
..Default::default()
}],
..Default::default()
});
}
let mut fm = FileMeta::new();
fm.unmarshal_msg(&self.metadata)?;
// `get_file_info_versions(..., false)` is the existing path that
// separates persisted tier free-version records into `free_versions`.
fm.get_file_info_versions(bucket, self.name.as_str(), false)
}
pub fn matches(&self, other: Option<&MetaCacheEntry>, strict: bool) -> (Option<MetaCacheEntry>, bool) {
if other.is_none() {
return (None, false);
@@ -899,7 +920,7 @@ impl<T: Clone + Debug + Send + Sync + 'static> Cache<T> {
mod tests {
use super::*;
use crate::test_data::create_real_xlmeta;
use crate::{FileMetaVersion, MetaDeleteMarker};
use crate::{FileMetaVersion, MetaDeleteMarker, TRANSITION_COMPLETE};
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::{
@@ -937,6 +958,196 @@ mod tests {
assert_eq!(objs, nobjs);
}
#[test]
fn file_info_versions_with_free_versions_includes_persisted_tier_cleanup_records() {
let version_id = Uuid::new_v4();
let remote_version_id = Uuid::new_v4();
let free_version_id = Uuid::new_v4();
let mut fm = FileMeta::new();
fm.add_version(FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/object".to_string(),
transition_version_id: Some(remote_version_id),
transition_tier: "WARM".to_string(),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
})
.expect("transitioned version should be added");
let mut delete_fi = FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
delete_fi.set_tier_free_version_id(&free_version_id.to_string());
fm.delete_version(&delete_fi)
.expect("transitioned delete should create free-version metadata");
let entry = MetaCacheEntry {
name: "object".to_string(),
metadata: fm.marshal_msg().expect("metadata should marshal"),
..Default::default()
};
let normal = entry.file_info_versions("bucket").expect("normal versions should parse");
assert!(normal.free_versions.is_empty());
let with_free = entry
.file_info_versions_with_free_versions("bucket")
.expect("versions with free versions should parse");
assert_eq!(with_free.free_versions.len(), 1);
assert!(with_free.free_versions[0].tier_free_version());
assert_eq!(with_free.free_versions[0].transitioned_objname, "remote/object");
assert_eq!(with_free.free_versions[0].transition_tier, "WARM");
assert_eq!(with_free.free_versions[0].transition_version_id, Some(remote_version_id));
}
#[test]
fn transitioned_delete_persists_recoverable_free_version_after_metadata_roundtrip() {
let version_id = Uuid::new_v4();
let remote_version_id = Uuid::new_v4();
let free_version_id = Uuid::new_v4();
let mut fm = FileMeta::new();
fm.add_version(FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/object".to_string(),
transition_version_id: Some(remote_version_id),
transition_tier: "WARM".to_string(),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
})
.expect("transitioned version should be added");
let mut delete_fi = FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
delete_fi.set_tier_free_version_id(&free_version_id.to_string());
fm.delete_version(&delete_fi)
.expect("transitioned delete should persist free-version metadata");
let encoded = fm.marshal_msg().expect("metadata should marshal");
let mut decoded = FileMeta::new();
decoded
.unmarshal_msg(&encoded)
.expect("metadata should survive process restart roundtrip");
let versions = decoded
.get_file_info_versions("bucket", "object", false)
.expect("versions with free versions should parse");
assert!(versions.versions.is_empty());
assert_eq!(versions.free_versions.len(), 1);
let free_version = &versions.free_versions[0];
assert_eq!(free_version.version_id, Some(free_version_id));
assert!(free_version.tier_free_version());
assert_eq!(free_version.transitioned_objname, "remote/object");
assert_eq!(free_version.transition_version_id, Some(remote_version_id));
assert_eq!(free_version.transition_tier, "WARM");
}
#[test]
fn skip_tier_free_version_does_not_persist_cleanup_record() {
let version_id = Uuid::new_v4();
let mut fm = FileMeta::new();
fm.add_version(FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/object".to_string(),
transition_version_id: Some(Uuid::new_v4()),
transition_tier: "WARM".to_string(),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
})
.expect("transitioned version should be added");
let mut delete_fi = FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
delete_fi.set_tier_free_version_id(&Uuid::new_v4().to_string());
delete_fi.set_skip_tier_free_version();
fm.delete_version(&delete_fi)
.expect("transitioned delete with skip flag should remove the local version");
let versions = fm
.get_file_info_versions("bucket", "object", false)
.expect("versions should parse after skipped cleanup");
assert!(versions.free_versions.is_empty());
assert_eq!(versions.versions.len(), 1);
assert!(versions.versions[0].deleted);
assert!(!versions.versions[0].tier_free_version());
}
#[test]
fn worker_style_free_version_delete_removes_persisted_cleanup_record() {
let version_id = Uuid::new_v4();
let remote_version_id = Uuid::new_v4();
let free_version_id = Uuid::new_v4();
let mut fm = FileMeta::new();
fm.add_version(FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
transition_status: TRANSITION_COMPLETE.to_string(),
transitioned_objname: "remote/object".to_string(),
transition_version_id: Some(remote_version_id),
transition_tier: "WARM".to_string(),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
})
.expect("transitioned version should be added");
let mut delete_fi = FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(version_id),
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
delete_fi.set_tier_free_version_id(&free_version_id.to_string());
fm.delete_version(&delete_fi)
.expect("transitioned delete should persist free-version metadata");
let mut free_delete_fi = FileInfo {
volume: "bucket".to_string(),
name: "object".to_string(),
version_id: Some(free_version_id),
deleted: true,
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
};
free_delete_fi.set_tier_free_version();
fm.delete_version(&free_delete_fi)
.expect("worker-style free-version delete should remove the cleanup record");
let versions = fm
.get_file_info_versions("bucket", "object", false)
.expect("versions should parse after free-version cleanup");
assert!(versions.free_versions.is_empty());
assert_eq!(versions.versions.len(), 1);
assert!(versions.versions[0].deleted);
assert!(!versions.versions[0].tier_free_version());
}
#[test]
fn test_resolve_rebuilds_metadata_from_merged_versions() {
let base_metadata = create_real_xlmeta().expect("base xl.meta");
@@ -0,0 +1,45 @@
# Tier Lifecycle Cleanup Stress Test
## Goal
Verify concurrent GET, ILM expiry, and tier free-version recovery do not produce `NoSuchVersion`, unbounded queue growth, or severe throughput regression.
## Setup
1. Start a RustFS cluster with a configured remote tier.
2. Upload at least 10,000 objects and transition them to the remote tier.
3. Configure lifecycle expiry for the transitioned prefix.
4. Enable lifecycle logs:
```bash
export RUST_LOG="info,rustfs_ecstore::bucket::lifecycle=debug"
```
## Workload
Run concurrent GET while triggering expiry:
```bash
seq 1 64 | xargs -I{} -P64 sh -c 'while true; do aws s3api get-object --bucket "$BUCKET" --key "prefix/obj-$((RANDOM % 10000))" /tmp/out.$$ >/dev/null 2>>get-errors.log; done'
```
## Required Checks
- `get-errors.log` contains no `NoSuchVersion`.
- Lifecycle queue pending count does not grow without bound.
- Missed free-version and tier journal counters may increase during injected failures, but later recovery drains persisted records.
- Recovery logs show cursor advancement across bucket and object markers.
- Duplicate recovery enqueue count stays bounded while remote tier delete is failing.
- Queue full or backpressure stops the current recovery pass instead of blocking normal lifecycle enqueue.
- Scan duration and scanned metadata entry count stay within the configured per-pass budget.
- p95 GET latency and lifecycle expiry throughput are recorded before and after the fix.
## Fault Injection
Repeat the workload while:
- stopping lifecycle workers
- forcing remote tier delete to fail
- restarting the RustFS process after local delete but before remote cleanup
After recovery, verify remote tier garbage does not remain for expired objects.