fix(ecstore): make transitioned cleanup crash-safe (#6978)

* fix(ecstore): fence transitioned object cleanup

* fix(ecstore): address ILM recovery review findings

* fix(ecstore): complete crash-safe tier cleanup recovery

* test(ecstore): avoid typo false positive

* fix(ecstore): stabilize decommission error buckets

* fix(ecstore): stabilize transition delete validation

* fix(ecstore): resume authorized tier delete dispatch

* fix(ecstore): satisfy feature clippy
This commit is contained in:
cxymds
2026-09-01 19:09:22 +08:00
committed by GitHub
parent bd66fa9dca
commit 6e26769265
46 changed files with 16999 additions and 1865 deletions
+7 -4
View File
@@ -1109,7 +1109,10 @@ fn build_heal_channel_request(hip: &HealInitParams) -> HealChannelRequest {
heal_request.update_parity = Some(hip.hs.update_parity);
heal_request.recursive = Some(recursive || root_cluster_target);
heal_request.dry_run = Some(hip.hs.dry_run);
heal_request.no_lock = Some(hip.hs.no_lock);
// `nolock` is retained in the admin wire shape for compatibility, but it
// is not transferable lock authority. Admin heals must enter the storage
// layer through its normal object-lock path.
heal_request.no_lock = Some(false);
heal_request.source = HealRequestSource::Admin;
heal_request
}
@@ -2588,7 +2591,7 @@ mod tests {
}
#[test]
fn test_build_heal_channel_request_preserves_client_options() {
fn test_build_heal_channel_request_preserves_safe_client_options() {
let hip = HealInitParams {
bucket: "bucket-a".to_string(),
obj_prefix: "prefix-a".to_string(),
@@ -2623,7 +2626,7 @@ mod tests {
assert_eq!(request.update_parity, Some(false));
assert_eq!(request.recursive, Some(true));
assert_eq!(request.dry_run, Some(true));
assert_eq!(request.no_lock, Some(true));
assert_eq!(request.no_lock, Some(false));
let envelope = rustfs_protos::heal_control::Envelope::start(
request,
@@ -2639,7 +2642,7 @@ mod tests {
else {
panic!("expected admin heal start command");
};
assert_eq!(request.no_lock, Some(true));
assert_eq!(request.no_lock, Some(false));
}
#[test]
@@ -29,7 +29,7 @@ use super::storage_api::test::object_utils::to_s3s_etag;
use super::storage_api::test::runtime::{MockWarmBackend, MockWarmOp, register_mock_tier as register_mock_tier_util};
use super::storage_api::test::{
ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints, StorageObjectInfo as ObjectInfo,
StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader,
StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader, install_all_v6_fleet_capability_proof,
};
use super::{multipart_usecase::DefaultMultipartUsecase, object_usecase::DefaultObjectUsecase};
use crate::app::bucket_usecase::DefaultBucketUsecase;
@@ -79,6 +79,12 @@ async fn setup_test_env() -> (Vec<PathBuf>, Arc<ECStore>) {
return (paths.clone(), ecstore.clone());
}
// Production startup installs this proof through the notification-system
// fleet probe. This isolated single-node harness constructs ECStore
// directly, so model that completed all-v6 probe before any journal permit
// or background expiry worker can exist.
install_all_v6_fleet_capability_proof();
let test_base_dir = format!("/tmp/rustfs_app_lifecycle_test_{}", Uuid::new_v4());
let temp_dir = PathBuf::from(&test_base_dir);
if temp_dir.exists() {
+1
View File
@@ -1270,6 +1270,7 @@ pub(crate) mod test {
pub(crate) mod data_usage {
pub(crate) use super::super::data_usage::*;
}
pub(crate) use crate::storage::storage_api::ecstore_bucket::install_all_v6_fleet_capability_proof;
pub(crate) use crate::storage::storage_api::test_consumer::{get_global_bucket_metadata_sys, set_bucket_metadata};
pub(crate) use crate::storage::storage_api::{
ECStore, Endpoint, Endpoints, PoolEndpoints, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader,
+169 -21
View File
@@ -83,6 +83,9 @@ const TIER_MUTATION_PEER_STATE_UNSPECIFIED_WIRE: i32 = 0;
const TIER_MUTATION_PEER_STATE_PREPARED_WIRE: i32 = 1;
const TIER_MUTATION_PEER_STATE_COMMITTED_WIRE: i32 = 2;
const TIER_MUTATION_PEER_STATE_ABORTED_WIRE: i32 = 3;
const TIER_MUTATION_FAILURE_CLASS_UNSPECIFIED_WIRE: i32 = 0;
const TIER_MUTATION_FAILURE_CLASS_PRE_DISPATCH_REJECTED_WIRE: i32 = 1;
const TIER_MUTATION_FAILURE_CLASS_AMBIGUOUS_WIRE: i32 = 2;
fn signal_service_response(success: bool, error_info: Option<String>) -> Response<SignalServiceResponse> {
Response::new(SignalServiceResponse {
@@ -178,7 +181,10 @@ fn remove_heal_control_replay(
static HEAL_CONTROL_REPLAY_CACHE: OnceLock<tokio::sync::Mutex<HashMap<String, Arc<HealControlReplayEntry>>>> = OnceLock::new();
static NODE_CAPABILITY_SERVER_EPOCH: LazyLock<Uuid> = LazyLock::new(Uuid::new_v4);
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
// v3 additionally promises the v6 tier-delete dispatch-manifest policy. The
// existing periodic topology probe carries both capabilities so normal object
// operations do not add another peer RPC.
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 3;
fn admit_heal_control_replay(
replay_cache: &mut HashMap<String, Arc<HealControlReplayEntry>>,
@@ -723,9 +729,28 @@ impl TierMutationControlRpcService {
.map_err(|_| Status::invalid_argument("tier mutation request length cannot be represented"))?;
verify_tonic_canonical_body_digest(request, &body)
.map_err(|err| Status::permission_denied(format!("tier mutation authentication failed: {err}")))?;
let store = self
.resolve_object_store()
.ok_or_else(|| Status::failed_precondition("tier mutation object store is not initialized"))?;
if !tier_mutation_protocol_version_is_supported(version) {
return Err(Status::failed_precondition(format!(
"unsupported tier mutation peer protocol version: {version}"
)));
}
let store = match self.resolve_object_store() {
Some(store) => store,
None if version == rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION => {
return tier_mutation_control_response(TierMutationControlResponseInput {
version,
phase,
mutation_id,
canonical_payload,
success: false,
state: TIER_MUTATION_PEER_STATE_UNSPECIFIED_WIRE,
applied: false,
error_info: Some("tier mutation object store is not initialized".to_string()),
failure_class: TIER_MUTATION_FAILURE_CLASS_PRE_DISPATCH_REJECTED_WIRE,
});
}
None => return Err(Status::failed_precondition("tier mutation object store is not initialized")),
};
match tier_mutation_peer::handle_tier_mutation_peer_request(store, version, phase, mutation_id, canonical_payload).await {
Ok(outcome) => tier_mutation_control_response(TierMutationControlResponseInput {
@@ -737,6 +762,7 @@ impl TierMutationControlRpcService {
state: tier_mutation_peer_state_to_proto_wire(outcome.state),
applied: outcome.applied,
error_info: None,
failure_class: TIER_MUTATION_FAILURE_CLASS_UNSPECIFIED_WIRE,
}),
Err(err) => tier_mutation_control_response(TierMutationControlResponseInput {
version,
@@ -746,7 +772,12 @@ impl TierMutationControlRpcService {
success: false,
state: TIER_MUTATION_PEER_STATE_UNSPECIFIED_WIRE,
applied: false,
error_info: Some(err.to_string()),
error_info: Some(bounded_tier_mutation_error_info(err.to_string())),
failure_class: if version == rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION {
TIER_MUTATION_FAILURE_CLASS_AMBIGUOUS_WIRE
} else {
TIER_MUTATION_FAILURE_CLASS_UNSPECIFIED_WIRE
},
}),
}
}
@@ -811,15 +842,35 @@ fn parse_tier_mutation_id(mutation_id: &str) -> Result<Uuid, Status> {
Ok(parsed)
}
fn tier_mutation_protocol_version_is_supported(version: u32) -> bool {
matches!(
version,
rustfs_protos::TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION | rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION
)
}
fn bounded_tier_mutation_error_info(mut error_info: String) -> String {
let limit = rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE;
if error_info.len() <= limit {
return error_info;
}
let mut boundary = limit;
while !error_info.is_char_boundary(boundary) {
boundary -= 1;
}
error_info.truncate(boundary);
error_info
}
fn validate_tier_mutation_payload_size(phase: rustfs_protos::TierMutationRpcPhase, payload_len: usize) -> Result<(), Status> {
let limit = match phase {
rustfs_protos::TierMutationRpcPhase::Prepare => rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE,
rustfs_protos::TierMutationRpcPhase::Commit => rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE,
rustfs_protos::TierMutationRpcPhase::Abort => {
if payload_len != 0 {
return Err(Status::invalid_argument("tier mutation abort payload must be empty"));
if payload_len == 0 {
return Err(Status::invalid_argument("tier mutation abort payload is empty"));
}
return Ok(());
rustfs_protos::TIER_MUTATION_RPC_MAX_ABORT_PAYLOAD_SIZE
}
_ => return Err(Status::invalid_argument("tier mutation rpc phase is unsupported")),
};
@@ -838,6 +889,7 @@ struct TierMutationControlResponseInput<'a> {
state: i32,
applied: bool,
error_info: Option<String>,
failure_class: i32,
}
fn tier_mutation_control_response(
@@ -853,6 +905,7 @@ fn tier_mutation_control_response(
state: input.state,
applied: input.applied,
error_info: input.error_info.as_deref(),
failure_class: input.failure_class,
})
.map_err(|_| Status::internal("tier mutation response length cannot be represented"))?;
let response_proof = sign_tonic_rpc_response_proof(&canonical_response)
@@ -863,6 +916,7 @@ fn tier_mutation_control_response(
applied: input.applied,
error_info: input.error_info,
response_proof: response_proof.into(),
failure_class: input.failure_class,
}))
}
@@ -2469,10 +2523,10 @@ impl Node for NodeService {
#[allow(unused_imports)]
mod tests {
use super::{
CollectMetricsOpts, DiskStore, Error, HEAL_CONTROL_PAYLOAD_MAX_SIZE, KMS_SIGNAL_SUBSYSTEM, MetricType, Node as _,
NodeService, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_PUBLICATION_LEASE_TTL_MS, SERVICE_SIGNAL_REFRESH_CONFIG,
SERVICE_SIGNAL_RELOAD_DYNAMIC, STORAGE_CLASS_SUB_SYS, admit_heal_control_replay,
CROSS_POOL_FENCE_SUPPORTED_VERSION, CollectMetricsOpts, DiskStore, Error, HEAL_CONTROL_PAYLOAD_MAX_SIZE,
KMS_SIGNAL_SUBSYSTEM, MetricType, Node as _, NodeService, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_PUBLICATION_LEASE_TTL_MS,
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, STORAGE_CLASS_SUB_SYS, admit_heal_control_replay,
background_rebalance_start_error_message, execute_heal_control_envelope_with_manager,
initialize_heal_topology_fingerprint, initialize_heal_topology_fingerprint_with_probe, legacy_scanner_activity_response,
make_heal_control_server, make_heal_control_server_with_cache, make_server, make_server_for_context,
@@ -2514,9 +2568,9 @@ mod tests {
RenameFileRequest, RenamePartRequest, ScannerActivityRequest, ScannerPublicationLeaseReleaseRequest,
ScannerPublicationLeaseRequest, ServerInfoRequest, SettlePartTransactionRequest, SignalServiceRequest,
SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, StartDecommissionRequest,
StartProfilingRequest, StatVolumeRequest, StopRebalanceRequest, TierMutationPeerState, TierMutationPrepareRequest,
UpdateMetacacheListingRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest,
WriteRequest,
StartProfilingRequest, StatVolumeRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationFailureClass,
TierMutationPeerState, TierMutationPrepareRequest, UpdateMetacacheListingRequest, UpdateMetadataRequest,
VerifyFileRequest, WriteAllRequest, WriteMetadataRequest, WriteRequest,
heal_control_service_client::HealControlServiceClient,
heal_control_service_server::{HealControlService as _, HealControlServiceServer},
node_service_client::NodeServiceClient,
@@ -3286,6 +3340,7 @@ mod tests {
#[tokio::test]
async fn tier_mutation_control_requires_body_bound_auth_before_store_lookup() {
let _ = rustfs_credentials::set_global_rpc_secret("tier-mutation-control-auth-test-secret".to_string());
let service = make_tier_mutation_control_server_for_context(None);
let mutation_id = uuid::Uuid::new_v4();
let unsigned = service
@@ -3317,8 +3372,26 @@ mod tests {
let unavailable = service
.prepare_tier_mutation(signed)
.await
.expect_err("authenticated request still requires initialized object store");
assert_eq!(unavailable.code(), tonic::Code::FailedPrecondition);
.expect("authenticated v4 pre-dispatch rejection should be signed")
.into_inner();
assert!(!unavailable.success);
assert_eq!(unavailable.failure_class, TierMutationFailureClass::PreDispatchRejected as i32);
assert_eq!(unavailable.error_info.as_deref(), Some("tier mutation object store is not initialized"));
let canonical =
rustfs_protos::canonical_tier_mutation_rpc_response_body(rustfs_protos::TierMutationRpcResponseProofInput {
version: rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: rustfs_protos::TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: b"intent",
success: false,
state: TierMutationPeerState::Unspecified as i32,
applied: false,
error_info: Some("tier mutation object store is not initialized"),
failure_class: TierMutationFailureClass::PreDispatchRejected as i32,
})
.expect("small response should encode");
crate::storage::storage_api::verify_tonic_rpc_response_proof(&canonical, &unavailable.response_proof)
.expect("v4 pre-dispatch rejection must authenticate its failure class");
}
#[tokio::test]
@@ -3337,12 +3410,13 @@ mod tests {
}
#[tokio::test]
async fn tier_mutation_control_rejects_old_protocol_version_before_store_lookup() {
async fn tier_mutation_control_rejects_unsupported_protocol_version_before_store_lookup() {
let service = make_tier_mutation_control_server_for_context(None);
let mutation_id = uuid::Uuid::new_v4();
let payload = Bytes::from_static(b"intent");
let unsupported_version = rustfs_protos::TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION - 1;
let mut request = Request::new(TierMutationPrepareRequest {
version: rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION - 1,
version: unsupported_version,
mutation_id: mutation_id.to_string(),
canonical_payload: payload,
});
@@ -3361,6 +3435,38 @@ mod tests {
.await
.expect_err("old tier mutation protocol version must fail closed");
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
assert_eq!(
error.message(),
format!("unsupported tier mutation peer protocol version: {}", unsupported_version)
);
}
#[tokio::test]
async fn tier_mutation_control_accepts_v3_before_store_lookup() {
let service = make_tier_mutation_control_server_for_context(None);
let mutation_id = uuid::Uuid::new_v4();
let payload = Bytes::from_static(b"intent");
let mut request = Request::new(TierMutationPrepareRequest {
version: rustfs_protos::TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION,
mutation_id: mutation_id.to_string(),
canonical_payload: payload,
});
let body = rustfs_protos::canonical_tier_mutation_rpc_body(
request.get_ref().version,
rustfs_protos::TierMutationRpcPhase::Prepare,
mutation_id,
&request.get_ref().canonical_payload,
)
.expect("v3 request should encode");
set_tonic_canonical_body_digest(&mut request, &body).expect("digest metadata should encode");
mark_v2_authenticated(&mut request);
let error = service
.prepare_tier_mutation(request)
.await
.expect_err("accepted v3 request should reach the missing-store check");
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
assert_eq!(error.message(), "tier mutation object store is not initialized");
}
#[tokio::test]
@@ -3379,6 +3485,32 @@ mod tests {
assert_eq!(error.code(), tonic::Code::InvalidArgument);
}
#[tokio::test]
async fn tier_mutation_control_rejects_invalid_abort_payload_before_auth_and_store_lookup() {
let service = make_tier_mutation_control_server_for_context(None);
let mutation_id = uuid::Uuid::new_v4();
let empty = service
.abort_tier_mutation(Request::new(TierMutationAbortRequest {
version: rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
mutation_id: mutation_id.to_string(),
canonical_payload: Bytes::new(),
}))
.await
.expect_err("empty abort must fail before digest construction");
assert_eq!(empty.code(), tonic::Code::InvalidArgument);
let oversized = Bytes::from(vec![0; rustfs_protos::TIER_MUTATION_RPC_MAX_ABORT_PAYLOAD_SIZE + 1]);
let error = service
.abort_tier_mutation(Request::new(TierMutationAbortRequest {
version: rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
mutation_id: mutation_id.to_string(),
canonical_payload: oversized,
}))
.await
.expect_err("oversized abort must fail before digest construction");
assert_eq!(error.code(), tonic::Code::InvalidArgument);
}
#[test]
fn tier_mutation_peer_state_wire_constants_match_generated_proto() {
assert_eq!(
@@ -3390,6 +3522,19 @@ mod tests {
assert_eq!(super::TIER_MUTATION_PEER_STATE_ABORTED_WIRE, TierMutationPeerState::Aborted as i32);
}
#[test]
fn tier_mutation_error_info_bound_preserves_utf8_and_byte_limit() {
let ascii = "a".repeat(rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE + 1);
let bounded = super::bounded_tier_mutation_error_info(ascii);
assert_eq!(bounded.len(), rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE);
let unicode = "".repeat(rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE);
let bounded = super::bounded_tier_mutation_error_info(unicode);
assert!(bounded.len() <= rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE);
assert!(bounded.is_char_boundary(bounded.len()));
assert!(bounded.chars().all(|character| character == '界'));
}
#[test]
fn tier_mutation_control_response_proof_binds_request_and_result() {
let _ = rustfs_credentials::set_global_rpc_secret("tier-mutation-control-response-proof-test-secret".to_string());
@@ -3404,6 +3549,7 @@ mod tests {
state: TierMutationPeerState::Unspecified as i32,
applied: false,
error_info: Some("store failed".to_string()),
failure_class: TierMutationFailureClass::Ambiguous as i32,
})
.expect("response proof should be signed")
.into_inner();
@@ -3417,6 +3563,7 @@ mod tests {
state: TierMutationPeerState::Unspecified as i32,
applied: false,
error_info: Some("store failed"),
failure_class: TierMutationFailureClass::Ambiguous as i32,
})
.expect("small mutation response should encode");
crate::storage::storage_api::verify_tonic_rpc_response_proof(&canonical, &response.response_proof)
@@ -3432,6 +3579,7 @@ mod tests {
state: TierMutationPeerState::Unspecified as i32,
applied: false,
error_info: Some("store failed"),
failure_class: TierMutationFailureClass::Ambiguous as i32,
})
.expect("small mutation response should encode");
let error = crate::storage::storage_api::verify_tonic_rpc_response_proof(&tampered, &response.response_proof)
@@ -3599,7 +3747,7 @@ mod tests {
}
#[tokio::test]
async fn cross_pool_fence_probe_authenticates_supported_v2_state() {
async fn cross_pool_fence_probe_authenticates_supported_v3_state() {
let _ = rustfs_credentials::set_global_rpc_secret("cross-pool-fence-node-service-test-secret".to_string());
let endpoints = heal_control_test_endpoints_with_coordinator("node-0", true);
assert!(
@@ -3664,7 +3812,7 @@ mod tests {
assert!(response.success);
assert_eq!(response.error_info, None);
assert_eq!(&response.result[..4], &2_u32.to_be_bytes());
assert_eq!(&response.result[..4], &CROSS_POOL_FENCE_SUPPORTED_VERSION.to_be_bytes());
let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(&response.result[4..])
.expect("capability identity should decode");
assert_eq!(topology_member, "node-a:9000");
+2
View File
@@ -404,6 +404,8 @@ pub(crate) mod ecstore_admin {
}
pub(crate) mod ecstore_bucket {
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::tier_delete_journal::test_util::install_all_v6_fleet_capability_proof;
pub(crate) use rustfs_ecstore::api::bucket::{
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, migration, object_lock, policy_sys,
replication, tagging, target, utils,