fix(ecstore): verify ILM metadata before decommission

This commit is contained in:
overtrue
2026-08-22 01:22:57 +08:00
parent 44351323e5
commit 50c208f716
9 changed files with 1246 additions and 39 deletions
+10
View File
@@ -78,6 +78,12 @@ test-group = 'embedded-test-ports'
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
test-group = 'ecstore-serial-flaky'
# The durable ILM decommission regression builds an isolated 8-disk store and
# deliberately takes target disks offline while checking read-quorum fencing.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(decommission_migrates_and_verifies_registered_durable_ilm_records)'
test-group = 'ecstore-serial-flaky'
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
@@ -190,6 +196,10 @@ test-group = 'embedded-test-ports'
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
test-group = 'ecstore-serial-flaky'
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(decommission_migrates_and_verifies_registered_durable_ilm_records)'
test-group = 'ecstore-serial-flaky'
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
# too (see the matching default-profile override near the top). No retries.
[[profile.ci.overrides]]
@@ -0,0 +1,261 @@
// 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 uuid::Uuid;
use super::{manual_transition_job, tier_delete_journal, transition_transaction};
use crate::error::{Error, Result};
pub(crate) const ILM_META_PREFIX: &str = "ilm";
const ILM_META_OBJECT_PREFIX: &str = "ilm/";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DurableIlmRecordKind {
TierDeleteJournal,
TransitionTransaction,
ManualTransitionJob,
ManualTransitionScope,
ManualTransitionTask,
ManualTransitionWorkerResult,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DurableIlmNamespace {
pub(crate) name: &'static str,
pub(crate) prefix: &'static str,
pub(crate) max_record_size: usize,
kind: DurableIlmRecordKind,
}
pub(crate) const TIER_DELETE_JOURNAL_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "tier-delete-journal",
prefix: "ilm/tier-delete-journal/",
max_record_size: 64 * 1024,
kind: DurableIlmRecordKind::TierDeleteJournal,
};
pub(crate) const TRANSITION_TRANSACTION_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "transition-transaction",
prefix: "ilm/transition-transactions/records",
max_record_size: transition_transaction::MAX_TRANSITION_TRANSACTION_SIZE,
kind: DurableIlmRecordKind::TransitionTransaction,
};
pub(crate) const MANUAL_TRANSITION_JOB_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-job",
prefix: "ilm/manual-transition/jobs",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionJob,
};
pub(crate) const MANUAL_TRANSITION_SCOPE_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-scope",
prefix: "ilm/manual-transition/scopes",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionScope,
};
pub(crate) const MANUAL_TRANSITION_TASK_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-task",
prefix: "ilm/manual-transition/tasks",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_TASK_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionTask,
};
pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "manual-transition-worker-result",
prefix: "ilm/manual-transition/results",
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
};
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 6] = [
TIER_DELETE_JOURNAL_NAMESPACE,
TRANSITION_TRANSACTION_NAMESPACE,
MANUAL_TRANSITION_JOB_NAMESPACE,
MANUAL_TRANSITION_SCOPE_NAMESPACE,
MANUAL_TRANSITION_TASK_NAMESPACE,
MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ValidatedDurableIlmRecord {
pub(crate) namespace: &'static str,
pub(crate) id_kind: &'static str,
pub(crate) id: String,
}
impl ValidatedDurableIlmRecord {
pub(crate) fn context(&self) -> String {
format!("namespace `{}` {} `{}`", self.namespace, self.id_kind, self.id)
}
}
fn path_is_in_namespace(path: &str, namespace: &DurableIlmNamespace) -> bool {
let Some(suffix) = path.strip_prefix(namespace.prefix) else {
return false;
};
if namespace.prefix.ends_with('/') {
!suffix.is_empty()
} else {
suffix.starts_with('/') && suffix.len() > 1
}
}
pub(crate) fn classify_durable_ilm_record(path: &str) -> Result<Option<&'static DurableIlmNamespace>> {
if path != ILM_META_PREFIX && !path.starts_with(ILM_META_OBJECT_PREFIX) {
return Ok(None);
}
DURABLE_ILM_NAMESPACES
.iter()
.find(|namespace| path_is_in_namespace(path, namespace))
.map(Some)
.ok_or_else(|| Error::other(format!("unregistered durable ILM namespace for path `{path}`")))
}
fn parse_manual_sharded_record(path: &str, prefix: &str) -> Result<(Uuid, String)> {
let suffix = path
.strip_prefix(prefix)
.and_then(|suffix| suffix.strip_prefix('/'))
.ok_or_else(|| Error::other("manual transition record path has wrong prefix"))?;
let mut parts = suffix.split('/');
let first = parts
.next()
.ok_or_else(|| Error::other("manual transition record first shard is missing"))?;
let second = parts
.next()
.ok_or_else(|| Error::other("manual transition record second shard is missing"))?;
let job_key = parts
.next()
.ok_or_else(|| Error::other("manual transition record job id is missing"))?;
let task_key = parts
.next()
.and_then(|file| file.strip_suffix(".json"))
.ok_or_else(|| Error::other("manual transition record task key is missing"))?;
if parts.next().is_some()
|| job_key.len() != 32
|| first != &job_key[..2]
|| second != &job_key[2..4]
|| !job_key
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
return Err(Error::other("manual transition record job id or shards are invalid"));
}
let job_id = Uuid::parse_str(job_key).map_err(|_| Error::other("manual transition record job id is invalid"))?;
Ok((job_id, task_key.to_string()))
}
pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<ValidatedDurableIlmRecord> {
let namespace =
classify_durable_ilm_record(path)?.ok_or_else(|| Error::other(format!("path `{path}` is not a durable ILM record")))?;
if data.len() > namespace.max_record_size {
return Err(Error::other(format!(
"durable ILM record exceeds {} byte limit",
namespace.max_record_size
)));
}
let (id_kind, id) = match namespace.kind {
DurableIlmRecordKind::TierDeleteJournal => {
let entry = tier_delete_journal::decode_tier_delete_journal_entry(data)?;
if tier_delete_journal::tier_delete_journal_object_name(&entry) != path {
return Err(Error::other("tier delete journal content does not match its path"));
}
let operation_id = path
.strip_prefix(namespace.prefix)
.and_then(|suffix| suffix.strip_suffix(".json"))
.ok_or_else(|| Error::other("tier delete journal path is invalid"))?;
("operation_id", operation_id.to_string())
}
DurableIlmRecordKind::TransitionTransaction => {
let transaction = transition_transaction::decode_transition_transaction_record(path, data)
.map_err(|err| Error::other(err.to_string()))?;
("transaction_id", transaction.transaction_id.to_string())
}
DurableIlmRecordKind::ManualTransitionJob => {
let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path)
.map_err(|err| Error::other(err.to_string()))?;
let canonical = manual_transition_job::manual_transition_job_record_object_name(job_id)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition job path is not canonical"));
}
manual_transition_job::ManualTransitionJobRecord::decode(job_id, data)
.map_err(|err| Error::other(err.to_string()))?;
("job_id", job_id.to_string())
}
DurableIlmRecordKind::ManualTransitionScope => {
let admission: manual_transition_job::ManualTransitionScopeAdmission =
serde_json::from_slice(data).map_err(Error::other)?;
admission.validate().map_err(|err| Error::other(err.to_string()))?;
let canonical = manual_transition_job::manual_transition_scope_record_object_name(&admission.scope_key)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition scope content does not match its path"));
}
("job_id", admission.job_id.to_string())
}
DurableIlmRecordKind::ManualTransitionTask => {
let (job_id, task_key) = parse_manual_sharded_record(path, namespace.prefix)?;
let canonical = manual_transition_job::manual_transition_task_object_name(job_id, &task_key)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition task path is not canonical"));
}
manual_transition_job::ManualTransitionTaskRecord::decode(job_id, &task_key, data)
.map_err(|err| Error::other(err.to_string()))?;
("job_id", job_id.to_string())
}
DurableIlmRecordKind::ManualTransitionWorkerResult => {
let (job_id, task_key) = parse_manual_sharded_record(path, namespace.prefix)?;
let canonical = manual_transition_job::manual_transition_worker_result_object_name(job_id, &task_key)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path {
return Err(Error::other("manual transition worker result path is not canonical"));
}
manual_transition_job::ManualTransitionWorkerResultRecord::decode(job_id, &task_key, data)
.map_err(|err| Error::other(err.to_string()))?;
("job_id", job_id.to_string())
}
};
Ok(ValidatedDurableIlmRecord {
namespace: namespace.name,
id_kind,
id,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_ilm_record_requires_namespace_registration() {
let err = classify_durable_ilm_record("ilm/future-durable/jobs/one.json")
.expect_err("unknown durable ILM path must fail closed");
assert!(err.to_string().contains("ilm/future-durable/jobs/one.json"));
}
#[test]
fn durable_ilm_registry_has_unique_non_overlapping_prefixes() {
for (index, namespace) in DURABLE_ILM_NAMESPACES.iter().enumerate() {
assert!(namespace.prefix.starts_with(ILM_META_OBJECT_PREFIX));
assert!(namespace.max_record_size > 0);
for other in DURABLE_ILM_NAMESPACES.iter().skip(index + 1) {
assert_ne!(namespace.prefix, other.prefix);
assert!(!path_is_in_namespace(namespace.prefix, other));
assert!(!path_is_in_namespace(other.prefix, namespace));
}
}
}
}
@@ -24,6 +24,10 @@ use crate::bucket::lifecycle::bucket_lifecycle_ops::{
ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport,
};
use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::durable_namespace::{
MANUAL_TRANSITION_JOB_NAMESPACE, MANUAL_TRANSITION_SCOPE_NAMESPACE, MANUAL_TRANSITION_TASK_NAMESPACE,
MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE,
};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::ObjectOptions;
@@ -34,10 +38,10 @@ use crate::store::ECStore;
pub const MANUAL_TRANSITION_JOB_SCHEMA: &str = "rustfs-manual-transition-job-v1";
pub const MANUAL_TRANSITION_TASK_SCHEMA: &str = "rustfs-manual-transition-task-v1";
pub const MANUAL_TRANSITION_WORKER_RESULT_SCHEMA: &str = "rustfs-manual-transition-worker-result-v1";
pub const MANUAL_TRANSITION_JOB_RECORD_PREFIX: &str = "ilm/manual-transition/jobs";
pub const MANUAL_TRANSITION_SCOPE_RECORD_PREFIX: &str = "ilm/manual-transition/scopes";
pub const MANUAL_TRANSITION_TASK_PREFIX: &str = "ilm/manual-transition/tasks";
pub const MANUAL_TRANSITION_WORKER_RESULT_PREFIX: &str = "ilm/manual-transition/results";
pub const MANUAL_TRANSITION_JOB_RECORD_PREFIX: &str = MANUAL_TRANSITION_JOB_NAMESPACE.prefix;
pub const MANUAL_TRANSITION_SCOPE_RECORD_PREFIX: &str = MANUAL_TRANSITION_SCOPE_NAMESPACE.prefix;
pub const MANUAL_TRANSITION_TASK_PREFIX: &str = MANUAL_TRANSITION_TASK_NAMESPACE.prefix;
pub const MANUAL_TRANSITION_WORKER_RESULT_PREFIX: &str = MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE.prefix;
pub const MAX_MANUAL_TRANSITION_JOB_RECORD_SIZE: usize = 64 * 1024;
pub const MAX_MANUAL_TRANSITION_TASK_RECORD_SIZE: usize = 16 * 1024;
pub const MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE: usize = 8 * 1024;
+4 -1
View File
@@ -16,6 +16,7 @@ pub mod bucket_lifecycle_audit;
pub mod bucket_lifecycle_ops;
mod config_boundary;
pub mod core;
mod durable_namespace;
pub mod evaluator;
pub mod manual_transition_job;
mod metadata_boundary;
@@ -32,4 +33,6 @@ pub mod tier_last_day_stats;
pub mod tier_sweeper;
pub mod transition_transaction;
pub(crate) const ILM_META_PREFIX: &str = "ilm";
pub(crate) use durable_namespace::{
ILM_META_PREFIX, ValidatedDurableIlmRecord, classify_durable_ilm_record, validate_durable_ilm_record,
};
@@ -20,6 +20,7 @@ use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::durable_namespace::TIER_DELETE_JOURNAL_NAMESPACE;
use crate::bucket::lifecycle::runtime_boundary;
use crate::bucket::lifecycle::tier_sweeper::{
Jentry, TierDeleteJournalState, TierDeleteSourceIdentity,
@@ -49,7 +50,7 @@ const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = TIER_DELETE_JOURNAL_NAMESPACE.prefix;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
@@ -21,6 +21,7 @@ use tracing::{debug, warn};
use uuid::Uuid;
use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPACE;
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::lifecycle::tier_sweeper::{
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
@@ -42,7 +43,7 @@ const TRANSITION_TRANSACTION_RECOVERY_INTERVAL: Duration = Duration::from_secs(6
const TRANSITION_TRANSACTION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
pub const TRANSITION_TRANSACTION_SCHEMA: &str = "rustfs-transition-transaction-v1";
pub const TRANSITION_TRANSACTION_PREFIX: &str = "ilm/transition-transactions";
pub const TRANSITION_TRANSACTION_RECORD_PREFIX: &str = "ilm/transition-transactions/records";
pub const TRANSITION_TRANSACTION_RECORD_PREFIX: &str = TRANSITION_TRANSACTION_NAMESPACE.prefix;
pub const MAX_TRANSITION_TRANSACTION_SIZE: usize = 64 * 1024;
pub type Result<T> = std::result::Result<T, TransitionTransactionError>;
+8
View File
@@ -406,6 +406,14 @@ where
Ok(data)
}
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
where
S: EcstoreObjectIO,
{
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, Some(max_bytes)).await?;
Ok(data)
}
/// Read an existing config object without treating an empty payload as absent.
/// Callers that validate their own payload format need to distinguish corruption
/// from `ConfigNotFound`.
+524 -29
View File
@@ -16,19 +16,23 @@ use crate::bucket::replication::replication_state_from_filemeta;
use crate::bucket::versioning_sys::BucketVersioningSys;
use crate::bucket::{
lifecycle::{
ILM_META_PREFIX, LifecycleExpiryConfigs,
ILM_META_PREFIX, LifecycleExpiryConfigs, ValidatedDurableIlmRecord,
bucket_lifecycle_audit::LcEventSrc,
bucket_lifecycle_ops::{
LifecycleOps, apply_expiry_on_transitioned_object, apply_expiry_rule_in, eval_action_from_lifecycle,
lifecycle_delete_all_versions_blocked_by_replication,
},
get_expiry_configs,
classify_durable_ilm_record, get_expiry_configs,
lifecycle::IlmAction,
validate_durable_ilm_record,
},
metadata_sys,
};
use crate::cache_value::metacache_set::{ListPathRawOptions, list_path_raw};
use crate::config::com::{CONFIG_PREFIX, read_config, read_config_no_lock, save_config, save_config_with_opts};
use crate::config::com::{
CONFIG_PREFIX, delete_config, read_config, read_config_limited_preserve_empty, read_config_no_lock, save_config,
save_config_with_opts,
};
use crate::data_movement;
use crate::data_movement::backpressure::{self, DataMovementOperation};
use crate::data_usage::DATA_USAGE_CACHE_NAME;
@@ -47,6 +51,7 @@ use crate::storage_api_contracts::{
admin::StorageAdminApi,
bucket::{BucketOperations, BucketOptions, MakeBucketOptions},
heal::HealOperations as _,
list::ListOperations as _,
namespace::NamespaceLocking as _,
object::{EcstoreObjectIO, ObjectIO as _, ObjectOperations as _},
};
@@ -60,6 +65,7 @@ use rmp_serde::Serializer;
use rustfs_common::defer;
use rustfs_common::heal_channel::HealOpts;
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
use serde::{Deserialize, Serialize};
@@ -95,6 +101,9 @@ const DECOMMISSION_META_PREFIXES: [&str; 3] = [CONFIG_PREFIX, BUCKET_META_PREFIX
const DECOMMISSION_TARGET_CAPACITY_OVERHEAD_PERCENT: usize = 30;
const DECOMMISSION_LISTING_MAX_ATTEMPTS: usize = 3;
const DECOMMISSION_LISTING_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
const DECOMMISSION_DURABLE_ILM_RECEIPT_ROOT: &str = "decommission/ilm-receipts";
const DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA: &str = "v1";
const DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE: usize = 16 * 1024;
/// Background decommission walks must tolerate slow object migrations; the
/// stall timeout is the drive-health bound, not the total listing duration.
const DECOMMISSION_BACKGROUND_WALKDIR_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
@@ -790,6 +799,135 @@ fn resolve_decommission_check_after_list_result(list_result: Result<()>, entry_e
if let Some(err) = entry_error { Err(err) } else { list_result }
}
fn validate_decommission_durable_ilm_copy(
path: &str,
source_record: &ValidatedDurableIlmRecord,
source: &[u8],
target: &[u8],
) -> Result<()> {
validate_durable_ilm_record(path, target).map_err(|err| {
Error::other(format!(
"target durable ILM record is invalid at path `{path}` {}: {err}",
source_record.context()
))
})?;
if source != target {
return Err(Error::other(format!(
"target durable ILM record content mismatch at path `{path}` {}",
source_record.context()
)));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct DecommissionDurableIlmReceipt {
source_path: String,
namespace: String,
id_kind: String,
id: String,
target_content_sha256: String,
}
impl DecommissionDurableIlmReceipt {
fn new(path: &str, record: &ValidatedDurableIlmRecord, target: &[u8]) -> Self {
Self {
source_path: path.to_string(),
namespace: record.namespace.to_string(),
id_kind: record.id_kind.to_string(),
id: record.id.clone(),
target_content_sha256: hex_sha256(target, ToOwned::to_owned),
}
}
fn context(&self) -> String {
format!("namespace `{}` {} `{}`", self.namespace, self.id_kind, self.id)
}
fn validate(&self) -> Result<()> {
let namespace = classify_durable_ilm_record(&self.source_path)?
.ok_or_else(|| Error::other(format!("receipt source path `{}` is not a durable ILM record", self.source_path)))?;
if namespace.name != self.namespace {
return Err(Error::other(format!(
"receipt namespace `{}` does not match source path `{}`",
self.namespace, self.source_path
)));
}
if self.id_kind.is_empty() || self.id.is_empty() {
return Err(Error::other(format!(
"receipt identity is missing for source path `{}`",
self.source_path
)));
}
if !is_sha256_checksum(&self.target_content_sha256) {
return Err(Error::other(format!(
"receipt target checksum is invalid for source path `{}` {}",
self.source_path,
self.context()
)));
}
Ok(())
}
fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let receipt_bytes = serde_json::to_vec(self)?;
let persisted = PersistedDecommissionDurableIlmReceipt {
schema: DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA.to_string(),
content_sha256: hex_sha256(&receipt_bytes, ToOwned::to_owned),
receipt: self.clone(),
};
let encoded = serde_json::to_vec(&persisted)?;
if encoded.len() > DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE {
return Err(Error::other(format!(
"durable ILM receipt exceeds maximum size for source path `{}` {}",
self.source_path,
self.context()
)));
}
Ok(encoded)
}
fn decode(data: &[u8]) -> Result<Self> {
if data.len() > DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE {
return Err(Error::other("durable ILM receipt exceeds maximum size"));
}
let persisted: PersistedDecommissionDurableIlmReceipt = serde_json::from_slice(data)?;
if persisted.schema != DECOMMISSION_DURABLE_ILM_RECEIPT_SCHEMA {
return Err(Error::other(format!("unsupported durable ILM receipt schema `{}`", persisted.schema)));
}
if !is_sha256_checksum(&persisted.content_sha256) {
return Err(Error::other("durable ILM receipt checksum is invalid"));
}
let receipt_bytes = serde_json::to_vec(&persisted.receipt)?;
let actual_checksum = hex_sha256(&receipt_bytes, ToOwned::to_owned);
if persisted.content_sha256 != actual_checksum {
return Err(Error::other("durable ILM receipt checksum mismatch"));
}
persisted.receipt.validate()?;
Ok(persisted.receipt)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PersistedDecommissionDurableIlmReceipt {
schema: String,
content_sha256: String,
receipt: DecommissionDurableIlmReceipt,
}
fn decommission_durable_ilm_receipt_prefix(cmd_line: &str) -> String {
let pool_key = hex_sha256(cmd_line.as_bytes(), ToOwned::to_owned);
format!("{DECOMMISSION_DURABLE_ILM_RECEIPT_ROOT}/{pool_key}/")
}
fn decommission_durable_ilm_receipt_path(prefix: &str, source_path: &str) -> String {
let source_key = hex_sha256(source_path.as_bytes(), ToOwned::to_owned);
format!("{prefix}{source_key}.json")
}
fn resolve_decommission_pool_meta_reload_result(result: Result<()>, stage: &str) -> Result<()> {
result.map_err(|err| Error::other(format!("decommission pool meta reload failed during {stage}: {err}")))
}
@@ -2759,6 +2897,11 @@ impl ECStore {
Ok(())
}
#[cfg(test)]
pub(crate) async fn promote_queued_decommission_for_test(&self, idx: usize) -> Result<()> {
self.promote_queued_decommission(idx).await
}
async fn record_decommission_terminal_reload_failure(&self, idx: usize, stage: &str, err: Error) -> Result<()> {
let changed = {
let mut pool_meta = self.pool_meta.write().await;
@@ -2962,6 +3105,12 @@ impl ECStore {
);
return Ok(());
}
let durable_ilm_record = if bucket == RUSTFS_META_BUCKET {
classify_durable_ilm_record(&entry.name)
.map_err(|err| with_decommission_entry_context("durable_ilm_namespace", &bucket, &entry.name, err))?
} else {
None
};
if self.decommission_cancel_requested(idx, &rx).await {
rx.cancel();
}
@@ -3274,7 +3423,8 @@ impl ECStore {
}
}
if should_cleanup_decommission_source_entry(decommissioned, fivs.versions.len(), expired) {
if should_cleanup_decommission_source_entry(decommissioned, fivs.versions.len(), expired) && durable_ilm_record.is_none()
{
if bucket_incarnation_fence.as_ref().is_some_and(|guard| guard.is_lock_lost()) {
return Err(Error::other("decommission bucket incarnation fence was lost before source cleanup"));
}
@@ -3319,6 +3469,17 @@ impl ECStore {
data_movement::SourceCleanupError::Storage(err) => err,
});
resolve_decommission_entry_cleanup_delete_result(cleanup_result, bucket.as_str(), entry.name.as_str())?
} else if durable_ilm_record.is_some() {
debug!(
event = EVENT_DECOMMISSION_ENTRY,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
bucket = %bucket,
object = %entry.name,
state = "retained_for_final_verification",
"Decommission durable ILM source retained for final verification"
);
} else if decommissioned != fivs.versions.len() || expired > 0 {
warn!(
event = EVENT_DECOMMISSION_ENTRY,
@@ -3637,6 +3798,17 @@ impl ECStore {
Ok(())
}
#[cfg(test)]
pub(crate) async fn decommission_pool_for_test(
self: &Arc<Self>,
rx: CancellationToken,
idx: usize,
pool: Arc<Sets>,
bucket: DecomBucketInfo,
) -> Result<()> {
self.decommission_pool(rx, idx, pool, bucket).await
}
#[tracing::instrument(skip(self, rx))]
pub async fn do_decommission_in_routine(self: &Arc<Self>, rx: CancellationToken, idx: usize) -> Result<()> {
defer!(|| async {
@@ -3785,7 +3957,6 @@ impl ECStore {
"failed to finalize decommission for pool {cmd_line}: post-check failed: {err}"
)));
}
info!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
@@ -3795,7 +3966,10 @@ impl ECStore {
state = "marking_completed",
"Decommission marking completed state"
);
resolve_decommission_terminal_mark_result(self.complete_decommission(idx).await, "completed", &cmd_line)?;
if let Err(err) = self.complete_decommission(idx).await {
resolve_decommission_terminal_mark_result(self.decommission_failed(idx).await, "failed", &cmd_line)?;
return Err(Error::other(format!("failed to finalize decommission for pool {cmd_line}: {err}")));
}
}
DecommissionFinalState::Failed => {
warn!(
@@ -3891,12 +4065,19 @@ impl ECStore {
#[tracing::instrument(skip(self))]
pub async fn complete_decommission(&self, idx: usize) -> Result<()> {
ensure_decommission_terminal_operation_supported(self.single_pool(), "complete decommission")?;
ensure_valid_decommission_pool_index(self.pools.len(), idx)?;
self.verify_decommission_durable_ilm_receipts(idx).await?;
let (should_reload_pool_meta, previous_pool_meta) = {
let (should_reload_pool_meta, completed, previous_pool_meta) = {
let mut pool_meta = self.pool_meta.write().await;
let previous_pool_meta = pool_meta.clone();
let changed = pool_meta.decommission_complete(idx);
(changed, changed.then_some(previous_pool_meta))
let completed = pool_meta
.pools
.get(idx)
.and_then(|pool| pool.decommission.as_ref())
.is_some_and(|decommission| decommission.complete);
(changed, completed, changed.then_some(previous_pool_meta))
};
{
@@ -3950,6 +4131,18 @@ impl ECStore {
}
}
if completed && let Err(err) = self.cleanup_decommission_durable_ilm_receipts(idx).await {
warn!(
event = EVENT_DECOMMISSION_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_POOLS,
pool_index = idx,
state = "receipt_cleanup_failed",
error = %err,
"Decommission durable ILM receipt cleanup failed"
);
}
Ok(())
}
@@ -4200,6 +4393,268 @@ impl ECStore {
Ok(ret)
}
async fn durable_ilm_receipt_prefix(&self, source_pool_idx: usize) -> Result<String> {
let pool_meta = self.pool_meta.read().await;
let cmd_line = pool_meta
.pools
.get(source_pool_idx)
.ok_or_else(|| invalid_decommission_pool_index_error(pool_meta.pools.len(), source_pool_idx))?
.cmd_line
.clone();
Ok(decommission_durable_ilm_receipt_prefix(&cmd_line))
}
async fn load_decommissioned_durable_ilm_target(
&self,
source_pool_idx: usize,
path: &str,
max_record_size: usize,
record_context: &str,
) -> Result<(usize, Vec<u8>)> {
let mut target = None::<(usize, Vec<u8>)>;
let mut first_read_error = None;
for (target_pool_idx, pool) in self.pools.iter().enumerate() {
if target_pool_idx == source_pool_idx {
continue;
}
match read_config_limited_preserve_empty(pool.clone(), path, max_record_size).await {
Ok(data) => {
if let Some((existing_pool_idx, existing)) = target.as_ref()
&& existing != &data
{
return Err(Error::other(format!(
"divergent target durable ILM records at path `{path}` {record_context} in pools {existing_pool_idx} and {target_pool_idx}"
)));
}
target = Some((target_pool_idx, data));
}
Err(err)
if matches!(&err, Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound)
|| is_err_object_not_found(&err)
|| is_err_version_not_found(&err) => {}
Err(err) => {
first_read_error.get_or_insert_with(|| {
Error::other(format!(
"failed to read target durable ILM record at path `{path}` {record_context} from pool {target_pool_idx}: {err}"
))
});
}
}
}
target.ok_or_else(|| {
first_read_error.unwrap_or_else(|| {
Error::other(format!("target durable ILM record is missing at path `{path}` {record_context}"))
})
})
}
async fn list_decommission_durable_ilm_receipts(&self, source_pool_idx: usize) -> Result<Vec<(usize, String)>> {
let prefix = self.durable_ilm_receipt_prefix(source_pool_idx).await?;
let mut receipts = Vec::new();
for (pool_idx, pool) in self.pools.iter().enumerate() {
if pool_idx == source_pool_idx {
continue;
}
let mut continuation = None;
loop {
let page = pool
.clone()
.list_objects_v2(RUSTFS_META_BUCKET, &prefix, continuation, None, 1000, false, None, false)
.await
.map_err(|err| {
Error::other(format!(
"failed to list durable ILM decommission receipts under `{prefix}` in pool {pool_idx}: {err}"
))
})?;
receipts.extend(page.objects.into_iter().map(|object| (pool_idx, object.name)));
if !page.is_truncated {
break;
}
continuation = Some(page.next_continuation_token.ok_or_else(|| {
Error::other(format!(
"durable ILM decommission receipt listing under `{prefix}` in pool {pool_idx} was truncated without a continuation token"
))
})?);
}
}
Ok(receipts)
}
async fn persist_decommission_durable_ilm_receipt(
&self,
source_pool_idx: usize,
target_pool_idx: usize,
receipt: &DecommissionDurableIlmReceipt,
) -> Result<()> {
let prefix = self.durable_ilm_receipt_prefix(source_pool_idx).await?;
let receipt_path = decommission_durable_ilm_receipt_path(&prefix, &receipt.source_path);
let encoded = receipt.encode().map_err(|err| {
Error::other(format!(
"failed to encode durable ILM decommission receipt `{receipt_path}` for source path `{}` {}: {err}",
receipt.source_path,
receipt.context()
))
})?;
save_config(self.pools[target_pool_idx].clone(), &receipt_path, encoded)
.await
.map_err(|err| {
Error::other(format!(
"failed to persist durable ILM decommission receipt `{receipt_path}` for source path `{}` {}: {err}",
receipt.source_path,
receipt.context()
))
})
}
async fn verify_decommission_durable_ilm_receipts(&self, source_pool_idx: usize) -> Result<()> {
let prefix = self.durable_ilm_receipt_prefix(source_pool_idx).await?;
for (receipt_pool_idx, receipt_path) in self.list_decommission_durable_ilm_receipts(source_pool_idx).await? {
let data = read_config_limited_preserve_empty(
self.pools[receipt_pool_idx].clone(),
&receipt_path,
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
)
.await
.map_err(|err| {
Error::other(format!(
"failed to read durable ILM decommission receipt `{receipt_path}` from pool {receipt_pool_idx}: {err}"
))
})?;
let receipt = DecommissionDurableIlmReceipt::decode(&data).map_err(|err| {
Error::other(format!(
"durable ILM decommission receipt `{receipt_path}` in pool {receipt_pool_idx} is invalid: {err}"
))
})?;
let expected_receipt_path = decommission_durable_ilm_receipt_path(&prefix, &receipt.source_path);
if receipt_path != expected_receipt_path {
return Err(Error::other(format!(
"durable ILM decommission receipt path `{receipt_path}` does not match source path `{}` {}",
receipt.source_path,
receipt.context()
)));
}
let namespace = classify_durable_ilm_record(&receipt.source_path)?
.ok_or_else(|| Error::other(format!("path `{}` is not a durable ILM record", receipt.source_path)))?;
let (_, target) = self
.load_decommissioned_durable_ilm_target(
source_pool_idx,
&receipt.source_path,
namespace.max_record_size,
&receipt.context(),
)
.await?;
let target_record = validate_durable_ilm_record(&receipt.source_path, &target).map_err(|err| {
Error::other(format!(
"target durable ILM record is invalid at path `{}` {}: {err}",
receipt.source_path,
receipt.context()
))
})?;
if target_record.namespace != receipt.namespace
|| target_record.id_kind != receipt.id_kind
|| target_record.id != receipt.id
{
return Err(Error::other(format!(
"target durable ILM record identity mismatch at path `{}` {}; decoded {}",
receipt.source_path,
receipt.context(),
target_record.context()
)));
}
let target_content_sha256 = hex_sha256(&target, ToOwned::to_owned);
if target_content_sha256 != receipt.target_content_sha256 {
return Err(Error::other(format!(
"target durable ILM record content mismatch at path `{}` {}",
receipt.source_path,
receipt.context()
)));
}
}
Ok(())
}
async fn cleanup_decommission_durable_ilm_receipts(&self, source_pool_idx: usize) -> Result<()> {
for (pool_idx, receipt_path) in self.list_decommission_durable_ilm_receipts(source_pool_idx).await? {
match delete_config(self.pools[pool_idx].clone(), &receipt_path).await {
Ok(()) | Err(Error::ConfigNotFound | Error::FileNotFound | Error::FileVersionNotFound) => {}
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {}
Err(err) => {
return Err(Error::other(format!(
"failed to clean durable ILM decommission receipt `{receipt_path}` from pool {pool_idx}: {err}"
)));
}
}
}
Ok(())
}
async fn verify_and_cleanup_decommissioned_durable_ilm_record(
&self,
source_pool_idx: usize,
source_set: Arc<SetDisks>,
path: &str,
) -> Result<()> {
let namespace = classify_durable_ilm_record(path)?
.ok_or_else(|| Error::other(format!("path `{path}` is not a durable ILM record")))?;
let source_versions = source_set
.load_file_info_versions_exact(RUSTFS_META_BUCKET, path)
.await
.map_err(|err| Error::other(format!("failed to load source durable ILM versions at path `{path}`: {err}")))?
.ok_or_else(|| Error::other(format!("source durable ILM record is missing at path `{path}`")))?;
let source = read_config_limited_preserve_empty(source_set.clone(), path, namespace.max_record_size)
.await
.map_err(|err| Error::other(format!("failed to read source durable ILM record at path `{path}`: {err}")))?;
let source_record = validate_durable_ilm_record(path, &source)
.map_err(|err| Error::other(format!("source durable ILM record is invalid at path `{path}`: {err}")))?;
let (target_pool_idx, target) = self
.load_decommissioned_durable_ilm_target(source_pool_idx, path, namespace.max_record_size, &source_record.context())
.await?;
validate_decommission_durable_ilm_copy(path, &source_record, &source, &target)?;
let receipt = DecommissionDurableIlmReceipt::new(path, &source_record, &target);
self.persist_decommission_durable_ilm_receipt(source_pool_idx, target_pool_idx, &receipt)
.await?;
let cleanup_result = data_movement::cleanup_source_entry_if_unchanged(
source_set,
RUSTFS_META_BUCKET,
path,
&source_versions,
&[],
data_movement::SourceCleanupBucketFence::default(),
"decommission durable ILM final sweep",
)
.await
.map_err(|err| {
Error::other(format!(
"source durable ILM cleanup failed at path `{path}` {}: {err}",
source_record.context()
))
});
resolve_decommission_entry_cleanup_delete_result(cleanup_result, RUSTFS_META_BUCKET, path)
}
#[cfg(test)]
pub(crate) async fn verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
&self,
source_pool_idx: usize,
source_set: Arc<SetDisks>,
path: &str,
) -> Result<()> {
self.verify_and_cleanup_decommissioned_durable_ilm_record(source_pool_idx, source_set, path)
.await
}
#[cfg(test)]
pub(crate) async fn decommission_durable_ilm_receipt_count_for_test(&self, source_pool_idx: usize) -> Result<usize> {
Ok(self.list_decommission_durable_ilm_receipts(source_pool_idx).await?.len())
}
#[cfg(test)]
pub(crate) async fn cleanup_decommission_durable_ilm_receipts_for_test(&self, source_pool_idx: usize) -> Result<()> {
self.cleanup_decommission_durable_ilm_receipts(source_pool_idx).await
}
async fn check_after_decommission(self: &Arc<Self>, idx: usize) -> Result<()> {
let buckets = self.get_buckets_to_decommission().await?;
let pool = self.pools[idx].clone();
@@ -4216,22 +4671,27 @@ impl ECStore {
let versions_found = Arc::new(AtomicUsize::new(0));
let entry_error = Arc::new(tokio::sync::Mutex::new(None::<Error>));
let first_remaining_path = Arc::new(tokio::sync::Mutex::new(None::<String>));
let callback_rx = CancellationToken::new();
let versions_found_cb = versions_found.clone();
let entry_error_cb = entry_error.clone();
let first_remaining_path_cb = first_remaining_path.clone();
let bucket_name = bucket_info.name.clone();
let lifecycle_config_cb = lifecycle_config.clone();
let object_lock_config_cb = object_lock_config.clone();
let store = Arc::clone(self);
let source_set = set.clone();
let callback_rx_cb = callback_rx.clone();
let callback: ListCallback = Arc::new(move |entry: MetaCacheEntry| {
let versions_found = versions_found_cb.clone();
let entry_error = entry_error_cb.clone();
let first_remaining_path = first_remaining_path_cb.clone();
let bucket_name = bucket_name.clone();
let lifecycle_config = lifecycle_config_cb.clone();
let object_lock_config = object_lock_config_cb.clone();
let store = Arc::clone(&store);
let source_set = source_set.clone();
let callback_rx = callback_rx_cb.clone();
Box::pin(async move {
if callback_rx.is_cancelled() {
@@ -4246,6 +4706,41 @@ impl ECStore {
return;
}
let durable_ilm_record = if bucket_name == RUSTFS_META_BUCKET {
match classify_durable_ilm_record(&entry.name) {
Ok(record) => record,
Err(err) => {
let mut first_err = entry_error.lock().await;
if first_err.is_none() {
*first_err = Some(with_decommission_entry_context(
"check_after_decommission.durable_ilm_namespace",
&bucket_name,
&entry.name,
err,
));
callback_rx.cancel();
}
return;
}
}
} else {
None
};
if durable_ilm_record.is_some() {
if let Err(err) = store
.verify_and_cleanup_decommissioned_durable_ilm_record(idx, source_set, &entry.name)
.await
{
let mut first_err = entry_error.lock().await;
if first_err.is_none() {
*first_err = Some(err);
callback_rx.cancel();
}
}
return;
}
let fivs = match load_decommission_entry_versions(
&entry,
&bucket_name,
@@ -4294,6 +4789,13 @@ impl ECStore {
remaining += 1;
}
if remaining > 0 {
let mut first_path = first_remaining_path.lock().await;
if first_path.is_none() {
*first_path = Some(format!("{bucket_name}/{}", entry.name));
}
}
versions_found.fetch_add(remaining, Ordering::Relaxed);
})
});
@@ -4306,17 +4808,29 @@ impl ECStore {
let versions_found = versions_found.load(Ordering::Relaxed);
if versions_found > 0 {
let first_remaining_path = first_remaining_path
.lock()
.await
.clone()
.unwrap_or_else(|| format!("{}/<unknown>", bucket_info.name));
return Err(Error::other(format!(
"at least {versions_found} object(s)/version(s) were found in bucket `{}` after decommissioning",
bucket_info.name
"at least {versions_found} object(s)/version(s) were found in bucket `{}` after decommissioning; first remaining path `{first_remaining_path}`",
bucket_info.name,
)));
}
}
}
self.verify_decommission_durable_ilm_receipts(idx).await?;
Ok(())
}
#[cfg(test)]
pub(crate) async fn check_after_decommission_for_test(self: &Arc<Self>, idx: usize) -> Result<()> {
self.check_after_decommission(idx).await
}
#[tracing::instrument(skip(self, rd))]
async fn decommission_object(
self: Arc<Self>,
@@ -5582,25 +6096,6 @@ mod pools_tests {
);
}
#[test]
fn test_decommission_meta_prefixes_cover_durable_ilm_records() {
let ilm_prefix = format!("{}/", crate::bucket::lifecycle::ILM_META_PREFIX);
let durable_prefixes = [
crate::bucket::lifecycle::tier_delete_journal::TIER_DELETE_JOURNAL_PREFIX,
crate::bucket::lifecycle::transition_transaction::TRANSITION_TRANSACTION_RECORD_PREFIX,
crate::bucket::lifecycle::manual_transition_job::MANUAL_TRANSITION_JOB_RECORD_PREFIX,
crate::bucket::lifecycle::manual_transition_job::MANUAL_TRANSITION_SCOPE_RECORD_PREFIX,
crate::bucket::lifecycle::manual_transition_job::MANUAL_TRANSITION_TASK_PREFIX,
crate::bucket::lifecycle::manual_transition_job::MANUAL_TRANSITION_WORKER_RESULT_PREFIX,
];
assert!(DECOMMISSION_META_PREFIXES.contains(&crate::bucket::lifecycle::ILM_META_PREFIX));
assert!(
durable_prefixes.iter().all(|prefix| prefix.starts_with(&ilm_prefix)),
"every durable ILM record must stay under the decommissioned ILM namespace"
);
}
#[test]
fn test_resume_reconciles_missing_decommission_meta_prefixes() {
let mut meta = PoolMeta {
+427 -3
View File
@@ -555,10 +555,18 @@ mod tests {
#[cfg(feature = "test-util")]
use crate::{
bucket::lifecycle::{
ILM_META_PREFIX,
bucket_lifecycle_ops::{ManualTransitionRunOptions, recover_manual_transition_jobs_once},
lifecycle::{TRANSITION_PENDING, TransitionOptions},
manual_transition_job::{
ManualTransitionJobRecord, ManualTransitionScopeAdmission, ManualTransitionTaskRecord,
ManualTransitionWorkerResult, ManualTransitionWorkerResultRecord, manual_transition_job_record_object_name,
manual_transition_scope_record_object_name, manual_transition_task_object_name,
manual_transition_worker_result_object_name, manual_transition_worker_result_task_key,
},
tier_delete_journal::{
TIER_DELETE_JOURNAL_PREFIX, persist_tier_delete_journal_entry, recover_tier_delete_journal_entries,
tier_delete_journal_object_name,
TIER_DELETE_JOURNAL_PREFIX, encode_tier_delete_journal_entry, persist_tier_delete_journal_entry,
recover_tier_delete_journal_entries, tier_delete_journal_object_name,
},
tier_sweeper::{
Jentry, TierDeleteJournalState, TierDeleteSourceIdentity, transitioned_delete_journal_entry_for_source,
@@ -570,12 +578,14 @@ mod tests {
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
inspect_transition_transaction_for_operator, load_transition_transaction_record,
recover_transition_transaction_records, save_transition_transaction_record,
transition_transaction_record_object_name,
},
},
bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG},
client::transition_api::ReaderImpl,
config::com,
disk::{RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE},
core::pools::DecomBucketInfo,
disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE},
runtime::{global::set_object_store_resolver, sources as runtime_sources},
services::tier::{
test_util::{MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, register_mock_tier},
@@ -3036,6 +3046,420 @@ mod tests {
));
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_migrates_and_verifies_registered_durable_ilm_records() {
let temp_dir = tempfile::tempdir().expect("create temp store dir");
let (ctx, store, _shutdown) =
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "durable-ilm-decommission", &[4, 4])).await;
let tier_name = "DECOMMISSION-ILM";
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
.await
.expect("tier lease should resolve")
.backend_identity();
let tier_entry = Jentry {
obj_name: "decommissioned-remote-object".to_string(),
version_id: "decommissioned-remote-version".to_string(),
tier_name: tier_name.to_string(),
backend_identity: Some(backend_identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: TierDeleteJournalState::Committed,
source: None,
};
let tier_path = tier_delete_journal_object_name(&tier_entry);
let tier_bytes = encode_tier_delete_journal_entry(&tier_entry).expect("tier journal should encode");
let transaction = TransitionTransaction::new(TransitionTransactionInit {
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
transaction_id: uuid::Uuid::new_v4(),
owner_epoch: uuid::Uuid::new_v4(),
write_id: uuid::Uuid::new_v4(),
source: TransitionSourceIdentity {
bucket: "source-bucket".to_string(),
object: "source-object".to_string(),
version_id: Some(uuid::Uuid::new_v4()),
data_dir: uuid::Uuid::new_v4(),
mod_time_unix_nanos: 1_770_000_000_000_000_000,
size: 42,
etag: "source-etag".to_string(),
version_mode: TransitionSourceVersionMode::Versioned,
},
tier_name: tier_name.to_string(),
backend_fingerprint: backend_identity,
not_after_unix_nanos: 1_780_000_000_000_000_000,
})
.expect("transition transaction should build");
let transaction_path = transition_transaction_record_object_name(transaction.transaction_id)
.expect("transition transaction path should build");
let transaction_bytes = transaction.encode().expect("transition transaction should encode");
let manual_job_id = uuid::Uuid::new_v4();
let manual_bucket = format!("manual-decommission-{}", manual_job_id.simple());
let manual_options = ManualTransitionRunOptions {
prefix: "logs/".to_string(),
tier: Some(tier_name.to_string()),
..Default::default()
};
let mut manual_job = ManualTransitionJobRecord::new(manual_job_id, &manual_bucket, &manual_options, "old-owner");
manual_job.scan_completed = true;
manual_job.report.enqueued = 1;
manual_job.lease_expires_at_unix_nanos = 0;
let manual_scope = ManualTransitionScopeAdmission::from_job(&manual_job);
let task_key = manual_transition_worker_result_task_key(&manual_bucket, "logs/a", None);
let manual_task = ManualTransitionTaskRecord::new(manual_job_id, &task_key, &manual_bucket, "logs/a", None, tier_name);
let manual_result =
ManualTransitionWorkerResultRecord::new(manual_job_id, &task_key, ManualTransitionWorkerResult::Completed);
let manual_job_path = manual_transition_job_record_object_name(manual_job_id).expect("manual job path should build");
let manual_scope_path =
manual_transition_scope_record_object_name(&manual_scope.scope_key).expect("manual scope path should build");
let manual_task_path =
manual_transition_task_object_name(manual_job_id, &task_key).expect("manual task path should build");
let manual_result_path = manual_transition_worker_result_object_name(manual_job_id, &task_key)
.expect("manual worker result path should build");
let manual_job_bytes = manual_job.encode().expect("manual job should encode");
let manual_scope_bytes = serde_json::to_vec(&manual_scope).expect("manual scope should encode");
let manual_task_bytes = manual_task.encode().expect("manual task should encode");
let manual_result_bytes = manual_result.encode().expect("manual result should encode");
let records = vec![
(tier_path.clone(), tier_bytes.clone()),
(transaction_path.clone(), transaction_bytes.clone()),
(manual_job_path.clone(), manual_job_bytes.clone()),
(manual_scope_path.clone(), manual_scope_bytes.clone()),
(manual_task_path.clone(), manual_task_bytes.clone()),
(manual_result_path.clone(), manual_result_bytes.clone()),
];
for (path, data) in &records {
com::save_config(store.pools[0].clone(), path, data.clone())
.await
.expect("durable ILM source record should persist");
}
let legacy_queue = [com::CONFIG_PREFIX, BUCKET_META_PREFIX]
.into_iter()
.map(|prefix| {
DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: prefix.to_string(),
}
.to_string()
})
.collect();
let legacy_pool_meta = {
let mut pool_meta = store.pool_meta.write().await;
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
queued: true,
queued_buckets: legacy_queue,
..Default::default()
});
pool_meta.clone()
};
legacy_pool_meta
.save(store.pools.clone())
.await
.expect("legacy decommission queue should persist before restart");
let mut restarted_pool_meta = PoolMeta::default();
restarted_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("legacy decommission queue should reload after restart");
*store.pool_meta.write().await = restarted_pool_meta;
store
.promote_queued_decommission_for_test(0)
.await
.expect("legacy queued decommission should resume");
let expected_ilm_queue = DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: ILM_META_PREFIX.to_string(),
}
.to_string();
{
let pool_meta = store.pool_meta.read().await;
let decommission = pool_meta.pools[0]
.decommission
.as_ref()
.expect("decommission state should remain present");
assert!(!decommission.queued);
assert!(decommission.queued_buckets.contains(&expected_ilm_queue));
}
let ilm_bucket = DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: ILM_META_PREFIX.to_string(),
};
for _ in 0..2 {
store
.decommission_pool_for_test(CancellationToken::new(), 0, store.pools[0].clone(), ilm_bucket.clone())
.await
.expect("durable ILM decommission should be idempotent");
}
for (path, expected) in &records {
assert_eq!(
com::read_config(store.pools[0].clone(), path)
.await
.expect("source should remain until the final sweep"),
*expected
);
assert_eq!(
com::read_config(store.pools[1].clone(), path)
.await
.expect("target should contain the migrated record"),
*expected
);
}
com::delete_config(store.pools[1].clone(), &manual_job_path)
.await
.expect("target manual job should delete");
let missing = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_job_path),
&manual_job_path,
)
.await
.expect_err("missing target must block source cleanup");
let missing = missing.to_string();
assert!(missing.contains(&manual_job_path) && missing.contains(&manual_job_id.to_string()));
assert_eq!(
com::read_config(store.pools[0].clone(), &manual_job_path)
.await
.expect("missing target must retain source"),
manual_job_bytes
);
com::save_config(store.pools[1].clone(), &manual_job_path, manual_job_bytes.clone())
.await
.expect("target manual job should restore");
com::save_config(store.pools[1].clone(), &transaction_path, b"{corrupt".to_vec())
.await
.expect("target transaction should corrupt deterministically");
let corrupt = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&transaction_path),
&transaction_path,
)
.await
.expect_err("corrupt target must block source cleanup");
let corrupt = corrupt.to_string();
assert!(corrupt.contains(&transaction_path) && corrupt.contains(&transaction.transaction_id.to_string()));
assert_eq!(
com::read_config(store.pools[0].clone(), &transaction_path)
.await
.expect("corrupt target must retain source"),
transaction_bytes
);
com::save_config(store.pools[1].clone(), &transaction_path, transaction_bytes.clone())
.await
.expect("target transaction should restore");
com::save_config(store.pools[1].clone(), &manual_scope_path, manual_scope_bytes.clone())
.await
.expect("target scope rewrite should invalidate cached metadata before the quorum check");
let target_scope_set = store.pools[1].get_disks_by_key(&manual_scope_path);
let original_target_scope_disks = {
let mut disks = target_scope_set.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(3) {
*disk = None;
}
original
};
let quorum_error = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_scope_path),
&manual_scope_path,
)
.await
.expect_err("target below read quorum must block source cleanup");
*target_scope_set.disks.write().await = original_target_scope_disks;
let quorum_error = quorum_error.to_string();
assert!(quorum_error.contains(&manual_scope_path) && quorum_error.contains(&manual_job_id.to_string()));
assert!(com::read_config(store.pools[0].clone(), &manual_scope_path).await.is_ok());
com::save_config(store.pools[1].clone(), &manual_task_path, manual_task_bytes.clone())
.await
.expect("target task rewrite should invalidate cached metadata before the quorum check");
let target_task_set = store.pools[1].get_disks_by_key(&manual_task_path);
let original_target_task_disks = {
let mut disks = target_task_set.disks.write().await;
let original = disks.clone();
for disk in disks.iter_mut().take(2) {
*disk = None;
}
original
};
let receipt_quorum_error = store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_task_path),
&manual_task_path,
)
.await
.expect_err("target read quorum without receipt write quorum must retain the source");
*target_task_set.disks.write().await = original_target_task_disks;
let receipt_quorum_error = receipt_quorum_error.to_string();
assert!(receipt_quorum_error.contains("receipt"));
assert!(receipt_quorum_error.contains(&manual_task_path));
assert!(receipt_quorum_error.contains(&manual_job_id.to_string()));
assert!(com::read_config(store.pools[0].clone(), &manual_task_path).await.is_ok());
store
.verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
0,
store.pools[0].get_disks_by_key(&manual_task_path),
&manual_task_path,
)
.await
.expect("healthy target should persist the receipt before source cleanup");
let unknown_path = "ilm/future-durable/jobs/one.json";
com::save_config(store.pools[0].clone(), unknown_path, b"{}".to_vec())
.await
.expect("unknown durable ILM record should persist for the guard test");
let unknown_migration = store
.decommission_pool_for_test(CancellationToken::new(), 0, store.pools[0].clone(), ilm_bucket)
.await
.expect_err("unregistered durable ILM namespace must block migration");
assert!(unknown_migration.to_string().contains(unknown_path));
let unknown_final_sweep = store
.check_after_decommission_for_test(0)
.await
.expect_err("unregistered durable ILM namespace must block completion");
assert!(unknown_final_sweep.to_string().contains(unknown_path));
com::delete_config(store.pools[0].clone(), unknown_path)
.await
.expect("unknown guard fixture should be removed before the successful final sweep");
store
.check_after_decommission_for_test(0)
.await
.expect("production final sweep should validate every target before cleanup");
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("durable ILM receipts should be listable"),
records.len(),
"every cleaned source record must have a durable validation receipt"
);
for (path, expected) in &records {
assert!(
matches!(com::read_config(store.pools[0].clone(), path).await, Err(Error::ConfigNotFound)),
"final sweep should remove the validated source `{path}`"
);
assert_eq!(
com::read_config(store.pools[1].clone(), path)
.await
.expect("final sweep must preserve the target"),
*expected
);
}
let mut crash_restarted_pool_meta = PoolMeta::default();
crash_restarted_pool_meta
.load(store.pools[0].clone(), store.pools.clone())
.await
.expect("pool metadata should reload after the simulated pre-complete crash");
*store.pool_meta.write().await = crash_restarted_pool_meta;
com::delete_config(store.pools[1].clone(), &manual_job_path)
.await
.expect("post-crash target manual job should delete");
let missing_after_crash = store
.complete_decommission(0)
.await
.expect_err("completion must reject a missing target after source cleanup and restart")
.to_string();
assert!(missing_after_crash.contains(&manual_job_path));
assert!(missing_after_crash.contains(&manual_job_id.to_string()));
assert!(
!store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("decommission state should survive restart")
.complete
);
com::save_config(store.pools[1].clone(), &manual_job_path, manual_job_bytes.clone())
.await
.expect("post-crash target manual job should restore");
com::save_config(store.pools[1].clone(), &transaction_path, b"{corrupt".to_vec())
.await
.expect("post-crash target transaction should corrupt deterministically");
let corrupt_after_crash = store
.complete_decommission(0)
.await
.expect_err("completion must reject a corrupt target after source cleanup and restart")
.to_string();
assert!(corrupt_after_crash.contains(&transaction_path));
assert!(corrupt_after_crash.contains(&transaction.transaction_id.to_string()));
com::save_config(store.pools[1].clone(), &transaction_path, transaction_bytes.clone())
.await
.expect("post-crash target transaction should restore");
store
.complete_decommission(0)
.await
.expect("completion should persist before receipt cleanup");
assert!(
store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("completed decommission state should remain present")
.complete
);
assert_eq!(
store
.decommission_durable_ilm_receipt_count_for_test(0)
.await
.expect("receipt cleanup should be observable"),
0
);
store
.cleanup_decommission_durable_ilm_receipts_for_test(0)
.await
.expect("receipt cleanup should be idempotent");
let tier_stats = recover_tier_delete_journal_entries(store.clone(), 100, None)
.await
.expect("tier journal recovery should consume the migrated record");
assert_eq!((tier_stats.scanned, tier_stats.deleted, tier_stats.failed), (1, 1, 0));
assert_eq!(
backend.remove_versions().await,
vec![(tier_entry.obj_name.clone(), tier_entry.version_id.clone())]
);
let transaction_stats = recover_transition_transaction_records(store.clone(), 100, None)
.await
.expect("transition transaction recovery should read the migrated record");
assert_eq!(
(
transaction_stats.scanned,
transaction_stats.recovered,
transaction_stats.retained,
transaction_stats.failed,
),
(1, 0, 1, 0)
);
let manual_stats = recover_manual_transition_jobs_once(store.clone(), 100, None)
.await
.expect("manual recovery should reconcile migrated job, scope, task, and result records");
assert_eq!(
(manual_stats.scanned, manual_stats.resumed, manual_stats.skipped, manual_stats.failed,),
(1, 1, 0, 0)
);
assert!(matches!(
com::read_config(store.clone(), &manual_scope_path).await,
Err(Error::ConfigNotFound)
));
}
#[cfg(feature = "test-util")]
async fn tier_delete_journal_count(store: Arc<crate::store::ECStore>) -> usize {
store