mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-14 09:03:14 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b3eb895d4 | |||
| 0d2dc23854 | |||
| 9699579ae1 | |||
| ca96da9fa2 | |||
| b1268173fb | |||
| c98d6b58a1 | |||
| b18ccefd1b |
@@ -131,8 +131,6 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod metadata_sys {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy,
|
||||
@@ -142,7 +140,7 @@ pub mod bucket {
|
||||
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
|
||||
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_quota_if_incarnation, update_under_transaction_lock,
|
||||
update_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,7 +316,7 @@ pub mod data_usage {
|
||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached, quota_object_size,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached,
|
||||
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
|
||||
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
|
||||
@@ -405,11 +403,8 @@ pub mod metrics {
|
||||
}
|
||||
|
||||
pub mod notification {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub use crate::services::notification_sys::{
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, acquire_cross_pool_fence_fleet_proof,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
|
||||
NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys,
|
||||
start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
@@ -469,8 +464,7 @@ pub mod set_disk {
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
pub use crate::set_disk::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,72 +50,6 @@ use uuid::Uuid;
|
||||
|
||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
struct ConfigWriteLockProbeState {
|
||||
bucket: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
static CONFIG_WRITE_LOCK_PROBES: std::sync::OnceLock<StdMutex<Vec<Arc<ConfigWriteLockProbeState>>>> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub struct ConfigWriteLockProbe {
|
||||
state: Arc<ConfigWriteLockProbeState>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl ConfigWriteLockProbe {
|
||||
pub fn install(bucket: &str) -> Self {
|
||||
let state = Arc::new(ConfigWriteLockProbeState {
|
||||
bucket: bucket.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut probes = CONFIG_WRITE_LOCK_PROBES
|
||||
.get_or_init(|| StdMutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("config write lock probe mutex should not poison");
|
||||
assert!(
|
||||
!probes.iter().any(|current| current.bucket == state.bucket),
|
||||
"config write lock probe must be unique for a bucket"
|
||||
);
|
||||
probes.push(Arc::clone(&state));
|
||||
drop(probes);
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub async fn wait_until_attempted(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("bucket config update should attempt the transaction lock");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl Drop for ConfigWriteLockProbe {
|
||||
fn drop(&mut self) {
|
||||
let mut probes = CONFIG_WRITE_LOCK_PROBES
|
||||
.get_or_init(|| StdMutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("config write lock probe mutex should not poison");
|
||||
probes.retain(|state| !Arc::ptr_eq(state, &self.state));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
fn notify_config_write_lock_attempt(bucket: &str) {
|
||||
let probe = CONFIG_WRITE_LOCK_PROBES
|
||||
.get_or_init(|| StdMutex::new(Vec::new()))
|
||||
.lock()
|
||||
.expect("config write lock probe mutex should not poison")
|
||||
.iter()
|
||||
.find(|probe| probe.bucket == bucket)
|
||||
.cloned();
|
||||
if let Some(probe) = probe {
|
||||
probe.arrived.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum MetadataLoadMode {
|
||||
Initial,
|
||||
@@ -656,31 +590,6 @@ pub async fn update_under_transaction_lock(
|
||||
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await
|
||||
}
|
||||
|
||||
pub async fn update_quota_if_incarnation(
|
||||
bucket: &str,
|
||||
data: Vec<u8>,
|
||||
expected_incarnation_id: Uuid,
|
||||
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
||||
) -> Result<OffsetDateTime> {
|
||||
let sys = get_bucket_metadata_sys()?;
|
||||
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
|
||||
sys.clone(),
|
||||
bucket,
|
||||
Some(expected_incarnation_id),
|
||||
))
|
||||
.await?;
|
||||
if !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof) {
|
||||
return Err(Error::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_capability",
|
||||
bucket: bucket.to_string(),
|
||||
object: rustfs_config::QUOTA_CONFIG_FILE.to_string(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data).await
|
||||
}
|
||||
|
||||
pub async fn update_bucket_targets_under_transaction_lock(
|
||||
guard: &BucketMetadataMutationGuard,
|
||||
bucket: &str,
|
||||
@@ -825,26 +734,7 @@ async fn acquire_transaction_lock_with_sys(
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
|
||||
.await?;
|
||||
let acquire = lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout());
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
{
|
||||
tokio::pin!(acquire);
|
||||
let mut notified = false;
|
||||
let guard = futures::future::poll_fn(|cx| match std::future::Future::poll(acquire.as_mut(), cx) {
|
||||
std::task::Poll::Pending => {
|
||||
if !notified {
|
||||
notify_config_write_lock_attempt(bucket);
|
||||
notified = true;
|
||||
}
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
std::task::Poll::Ready(result) => std::task::Poll::Ready(result),
|
||||
})
|
||||
.await?;
|
||||
Ok(guard)
|
||||
}
|
||||
#[cfg(not(any(test, feature = "test-util")))]
|
||||
Ok(acquire.await?)
|
||||
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
|
||||
}
|
||||
|
||||
/// The lock resource name is deliberately still the `bucket-targets` one it
|
||||
@@ -999,37 +889,6 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_in(
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the quota configuration and bucket incarnation from the same
|
||||
/// authoritative metadata blob while the caller holds the bucket metadata
|
||||
/// transaction read lock.
|
||||
pub(crate) async fn get_quota_config_and_incarnation_from_disk_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<(Option<BucketQuota>, Uuid, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
|
||||
match bucket_meta_sys
|
||||
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
|
||||
.await?
|
||||
{
|
||||
BucketMetadataAuthority::Authoritative(metadata)
|
||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||
{
|
||||
Ok((
|
||||
metadata.quota_config.clone(),
|
||||
metadata.bucket_incarnation_id,
|
||||
metadata.quota_config_updated_at,
|
||||
))
|
||||
}
|
||||
BucketMetadataAuthority::Authoritative(_) => {
|
||||
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
|
||||
}
|
||||
BucketMetadataAuthority::MissingBucket => Err(Error::BucketNotFound(bucket.to_string())),
|
||||
BucketMetadataAuthority::Fabricated => Err(Error::other(format!("bucket quota metadata is not authoritative: {bucket}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
@@ -52,7 +52,6 @@ impl QuotaChecker {
|
||||
) -> Result<QuotaCheckResult, QuotaError> {
|
||||
let start_time = Instant::now();
|
||||
let quota_config = self.get_quota_config(bucket).await?;
|
||||
let uses_durable_reservations = quota_config.uses_durable_reservations();
|
||||
|
||||
// If no quota limit is set, allow operation
|
||||
let quota_limit = match quota_config.quota {
|
||||
@@ -68,7 +67,6 @@ impl QuotaChecker {
|
||||
quota_limit: None,
|
||||
operation_size,
|
||||
remaining: None,
|
||||
uses_durable_reservations,
|
||||
});
|
||||
}
|
||||
Some(q) => q,
|
||||
@@ -76,17 +74,14 @@ impl QuotaChecker {
|
||||
|
||||
let current_usage = self.get_real_time_usage(bucket).await?;
|
||||
|
||||
let admission_size = if uses_durable_reservations { 0 } else { operation_size };
|
||||
let expected_usage = match operation {
|
||||
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
|
||||
current_usage.saturating_add(admission_size)
|
||||
}
|
||||
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => current_usage + operation_size,
|
||||
QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size),
|
||||
};
|
||||
|
||||
let allowed = match operation {
|
||||
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
|
||||
quota_config.check_operation_allowed(current_usage, admission_size)
|
||||
quota_config.check_operation_allowed(current_usage, operation_size)
|
||||
}
|
||||
QuotaOperation::DeleteObject => true,
|
||||
};
|
||||
@@ -110,7 +105,6 @@ impl QuotaChecker {
|
||||
quota_limit: Some(quota_limit),
|
||||
operation_size,
|
||||
remaining,
|
||||
uses_durable_reservations,
|
||||
};
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
@@ -164,26 +158,6 @@ impl QuotaChecker {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_durable_quota_config_if_incarnation(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
quota: BucketQuota,
|
||||
expected_incarnation_id: uuid::Uuid,
|
||||
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
||||
) -> Result<OffsetDateTime, QuotaError> {
|
||||
let json_data = serde_json::to_vec("a).map_err(|e| QuotaError::InvalidConfig {
|
||||
reason: format!("Failed to serialize quota config: {}", e),
|
||||
})?;
|
||||
let start_time = Instant::now();
|
||||
let updated_at =
|
||||
crate::bucket::metadata_sys::update_quota_if_incarnation(bucket, json_data, expected_incarnation_id, proof)
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
async fn set_quota_config_for_incarnation(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
@@ -381,7 +355,6 @@ mod tests {
|
||||
quota_limit: None,
|
||||
operation_size: 1024,
|
||||
remaining: None,
|
||||
uses_durable_reservations: false,
|
||||
};
|
||||
|
||||
assert!(result.allowed);
|
||||
@@ -405,13 +378,4 @@ mod tests {
|
||||
let allowed = quota.check_operation_allowed(512, 1024);
|
||||
assert!(!allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_quota_rejects_full_operation_while_v1_defers_net_growth() {
|
||||
let legacy: BucketQuota = serde_json::from_str(r#"{"quota":5}"#).expect("legacy quota should parse");
|
||||
let durable = BucketQuota::new(Some(5));
|
||||
|
||||
assert!(!legacy.check_operation_allowed(4, 2));
|
||||
assert!(durable.uses_durable_reservations());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,98 +13,38 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub mod checker;
|
||||
pub(crate) mod reservation;
|
||||
|
||||
use crate::error::Result;
|
||||
use rustfs_config::{
|
||||
QUOTA_API_PATH, QUOTA_EXCEEDED_ERROR_CODE, QUOTA_INTERNAL_ERROR_CODE, QUOTA_INVALID_CONFIG_ERROR_CODE,
|
||||
QUOTA_NOT_FOUND_ERROR_CODE,
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum QuotaType {
|
||||
/// Hard quota accounting.
|
||||
/// Hard quota: reject immediately when exceeded
|
||||
#[default]
|
||||
#[serde(alias = "HARD", alias = "hard")]
|
||||
Hard,
|
||||
}
|
||||
|
||||
pub(crate) const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
|
||||
|
||||
/// Bucket quota configuration. quota_type defaults to Hard when omitted.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
#[derive(Debug, Deserialize, Serialize, Default, Clone, PartialEq)]
|
||||
pub struct BucketQuota {
|
||||
#[serde(default)]
|
||||
pub quota: Option<u64>,
|
||||
/// Defaults to Hard when missing.
|
||||
#[serde(default)]
|
||||
pub quota_type: QuotaType,
|
||||
/// Optional durable reservation protocol. The wire format gives older
|
||||
/// nodes a zero hard quota so a mixed-version fleet fails closed.
|
||||
pub reservation_protocol: Option<u32>,
|
||||
/// Timestamp when this quota configuration was set (for audit purposes)
|
||||
#[serde(default, with = "time::serde::rfc3339::option")]
|
||||
pub created_at: Option<OffsetDateTime>,
|
||||
/// Accept updated_at for compatibility; not used.
|
||||
pub updated_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct BucketQuotaWire {
|
||||
#[serde(default)]
|
||||
quota: Option<u64>,
|
||||
#[serde(default)]
|
||||
quota_type: QuotaType,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reservation_protocol: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reservation_quota: Option<u64>,
|
||||
#[serde(default, with = "time::serde::rfc3339::option")]
|
||||
created_at: Option<OffsetDateTime>,
|
||||
#[serde(default, with = "time::serde::rfc3339::option", skip_serializing_if = "Option::is_none")]
|
||||
updated_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl Serialize for BucketQuota {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let durable = self.uses_durable_reservations();
|
||||
BucketQuotaWire {
|
||||
quota: if durable { Some(0) } else { self.quota },
|
||||
quota_type: self.quota_type.clone(),
|
||||
reservation_protocol: self.reservation_protocol,
|
||||
reservation_quota: if durable { self.quota } else { None },
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for BucketQuota {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = BucketQuotaWire::deserialize(deserializer)?;
|
||||
let quota = if wire.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1) {
|
||||
Some(
|
||||
wire.reservation_quota
|
||||
.ok_or_else(|| D::Error::custom("reservation_quota is required for reservation protocol v1"))?,
|
||||
)
|
||||
} else {
|
||||
wire.quota
|
||||
};
|
||||
Ok(Self {
|
||||
quota,
|
||||
quota_type: wire.quota_type,
|
||||
reservation_protocol: wire.reservation_protocol,
|
||||
created_at: wire.created_at,
|
||||
updated_at: wire.updated_at,
|
||||
})
|
||||
}
|
||||
pub updated_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl BucketQuota {
|
||||
@@ -123,7 +63,6 @@ impl BucketQuota {
|
||||
Self {
|
||||
quota,
|
||||
quota_type: QuotaType::Hard,
|
||||
reservation_protocol: quota.map(|_| QUOTA_RESERVATION_PROTOCOL_V1),
|
||||
created_at: Some(now),
|
||||
updated_at: None,
|
||||
}
|
||||
@@ -133,19 +72,7 @@ impl BucketQuota {
|
||||
self.quota
|
||||
}
|
||||
|
||||
pub fn uses_durable_reservations(&self) -> bool {
|
||||
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1)
|
||||
}
|
||||
|
||||
pub fn has_unsupported_reservation_protocol(&self) -> bool {
|
||||
self.reservation_protocol
|
||||
.is_some_and(|version| version != QUOTA_RESERVATION_PROTOCOL_V1)
|
||||
}
|
||||
|
||||
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
|
||||
if operation_size == 0 {
|
||||
return true;
|
||||
}
|
||||
if let Some(quota_limit) = self.quota {
|
||||
current_usage.saturating_add(operation_size) <= quota_limit
|
||||
} else {
|
||||
@@ -167,7 +94,6 @@ pub struct QuotaCheckResult {
|
||||
pub quota_limit: Option<u64>,
|
||||
pub operation_size: u64,
|
||||
pub remaining: Option<u64>,
|
||||
pub uses_durable_reservations: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -284,59 +210,7 @@ mod tests {
|
||||
let buf = q.marshal_msg().expect("marshal");
|
||||
let restored = BucketQuota::unmarshal(&buf).expect("unmarshal");
|
||||
assert_eq!(q.quota, restored.quota);
|
||||
assert_eq!(restored.quota_type, QuotaType::Hard);
|
||||
assert_eq!(restored.reservation_protocol, Some(QUOTA_RESERVATION_PROTOCOL_V1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_quota_keeps_the_legacy_compatible_type() {
|
||||
let quota = BucketQuota::new(None);
|
||||
|
||||
assert_eq!(quota.quota_type, QuotaType::Hard);
|
||||
assert_eq!(quota.reservation_protocol, None);
|
||||
assert!(!quota.uses_durable_reservations());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_quota_makes_legacy_nodes_fail_closed() {
|
||||
let json = serde_json::to_vec(&BucketQuota::new(Some(2048))).expect("durable quota should serialize");
|
||||
let quota: BucketQuota = serde_json::from_slice(&json).expect("current quota version should parse");
|
||||
assert!(quota.uses_durable_reservations());
|
||||
assert_eq!(quota.quota, Some(2048));
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum LegacyQuotaType {
|
||||
Hard,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyBucketQuota {
|
||||
#[allow(dead_code)]
|
||||
quota: Option<u64>,
|
||||
#[allow(dead_code)]
|
||||
quota_type: LegacyQuotaType,
|
||||
}
|
||||
let legacy = serde_json::from_slice::<LegacyBucketQuota>(&json)
|
||||
.expect("legacy readers should ignore the reservation protocol field");
|
||||
assert_eq!(legacy.quota, Some(0));
|
||||
assert!(matches!(legacy.quota_type, LegacyQuotaType::Hard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_reservation_protocol_does_not_activate_v1() {
|
||||
let quota: BucketQuota =
|
||||
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":2,"reservation_quota":2048}"#)
|
||||
.expect("future protocol should remain parseable");
|
||||
|
||||
assert!(!quota.uses_durable_reservations());
|
||||
assert!(quota.has_unsupported_reservation_protocol());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reservation_protocol_v1_requires_reservation_quota() {
|
||||
let err = serde_json::from_str::<BucketQuota>(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":1}"#)
|
||||
.expect_err("v1 without its authoritative quota must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("reservation_quota is required"));
|
||||
assert_eq!(q.quota_type, restored.quota_type);
|
||||
}
|
||||
|
||||
/// unmarshal accepts format without quota_type
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -233,11 +233,17 @@ pub struct NsScannerCapabilityRequest {
|
||||
#[async_trait]
|
||||
pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
|
||||
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
|
||||
self.open_read(request).await
|
||||
}
|
||||
/// Opens an owned-chunk stream when this transport can retain receive-buffer
|
||||
/// ownership. `None` preserves the established `open_read` fallback.
|
||||
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
self.open_read_chunks(request).await
|
||||
}
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
|
||||
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
|
||||
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
|
||||
@@ -269,6 +275,15 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
Ok(Box::new(
|
||||
HttpReader::new_fresh_connection_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
@@ -278,6 +293,16 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
)))
|
||||
}
|
||||
|
||||
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<ChunkReaderBox>> {
|
||||
let url = build_read_file_stream_url(&request);
|
||||
let mut headers = json_headers();
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
Ok(Some(Box::new(
|
||||
HttpChunkReader::new_fresh_connection_with_stall_timeout(url, Method::GET, headers, None, request.stall_timeout)
|
||||
.await?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
|
||||
let nonce = server_epoch.map(|_| Uuid::new_v4());
|
||||
|
||||
@@ -248,16 +248,6 @@ fn decode_remote_version_state_capability(expected_member: &str, result: &[u8])
|
||||
Ok(server_epoch)
|
||||
}
|
||||
|
||||
fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> Result<(u32, Uuid)> {
|
||||
let version = result
|
||||
.get(..4)
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.map(u32::from_be_bytes)
|
||||
.ok_or_else(|| Error::other("peer returned an invalid cross-pool fence capability version"))?;
|
||||
let epoch = decode_remote_version_state_capability(expected_member, &result[4..])?;
|
||||
Ok((version, epoch))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerLiveEventsBatch {
|
||||
pub events: Vec<u8>,
|
||||
@@ -1298,16 +1288,6 @@ impl PeerRestClient {
|
||||
Ok((self.topology_member.clone(), epoch))
|
||||
}
|
||||
|
||||
pub async fn probe_cross_pool_fence(&self, topology_fingerprint: String) -> Result<(String, u32, Uuid)> {
|
||||
let mut probe = rustfs_protos::CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX.to_vec();
|
||||
probe.extend_from_slice(Uuid::new_v4().as_bytes());
|
||||
let result = self
|
||||
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
|
||||
.await?;
|
||||
let (supported_version, epoch) = decode_cross_pool_fence_capability(&self.topology_member, &result)?;
|
||||
Ok((self.topology_member.clone(), supported_version, epoch))
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
@@ -2758,24 +2738,6 @@ mod tests {
|
||||
assert!(decode_remote_version_state_capability("node-a:9000", &nil).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_pool_fence_capability_decoder_fails_closed() {
|
||||
let epoch = Uuid::new_v4();
|
||||
let result = rustfs_protos::encode_cross_pool_fence_capability(1, "node-a:9000", epoch.as_bytes())
|
||||
.expect("small capability response should encode");
|
||||
assert_eq!(
|
||||
decode_cross_pool_fence_capability("node-a:9000", &result).expect("valid capability should decode"),
|
||||
(1, epoch)
|
||||
);
|
||||
for malformed in [&[][..], &[0, 0, 0][..], &result[..result.len() - 1]] {
|
||||
assert!(decode_cross_pool_fence_capability("node-a:9000", malformed).is_err());
|
||||
}
|
||||
assert!(decode_cross_pool_fence_capability("node-b:9000", &result).is_err());
|
||||
let nil = rustfs_protos::encode_cross_pool_fence_capability(1, "node-a:9000", Uuid::nil().as_bytes())
|
||||
.expect("small capability response should encode");
|
||||
assert!(decode_cross_pool_fence_capability("node-a:9000", &nil).is_err());
|
||||
}
|
||||
|
||||
struct TierMutationResponseFixture<'a> {
|
||||
version: u32,
|
||||
phase: TierMutationRpcPhase,
|
||||
|
||||
@@ -57,15 +57,17 @@ use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
io::Cursor,
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::time;
|
||||
use tokio::{
|
||||
io::{self, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
io::{self, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf},
|
||||
net::TcpStream,
|
||||
time::timeout,
|
||||
};
|
||||
@@ -214,6 +216,231 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn is_retryable_remote_body_error(error: &io::Error) -> bool {
|
||||
if error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<rustfs_rio::BodyStalled>())
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::ConnectionAborted
|
||||
| io::ErrorKind::UnexpectedEof
|
||||
)
|
||||
}
|
||||
|
||||
fn resumed_read_request(request: &ReadStreamRequest, emitted: usize) -> io::Result<ReadStreamRequest> {
|
||||
let offset = request
|
||||
.offset
|
||||
.checked_add(emitted)
|
||||
.ok_or_else(|| io::Error::other("remote read resume offset overflow"))?;
|
||||
let length = if request.length == 0 {
|
||||
0
|
||||
} else {
|
||||
request
|
||||
.length
|
||||
.checked_sub(emitted)
|
||||
.ok_or_else(|| io::Error::other("remote read resume offset exceeds requested length"))?
|
||||
};
|
||||
Ok(ReadStreamRequest {
|
||||
offset,
|
||||
length,
|
||||
..request.clone()
|
||||
})
|
||||
}
|
||||
|
||||
type ReadResumeFuture = tokio::task::JoinHandle<Result<FileReader>>;
|
||||
|
||||
struct RetryingRemoteReader {
|
||||
reader: Option<FileReader>,
|
||||
transport: Arc<dyn InternodeDataTransport>,
|
||||
request: ReadStreamRequest,
|
||||
emitted: usize,
|
||||
retried: bool,
|
||||
resume: Option<ReadResumeFuture>,
|
||||
}
|
||||
|
||||
impl RetryingRemoteReader {
|
||||
fn new(reader: FileReader, transport: Arc<dyn InternodeDataTransport>, request: ReadStreamRequest) -> Self {
|
||||
Self {
|
||||
reader: Some(reader),
|
||||
transport,
|
||||
request,
|
||||
emitted: 0,
|
||||
retried: false,
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_resume(&mut self) -> io::Result<()> {
|
||||
if self.request.length != 0 && self.emitted >= self.request.length {
|
||||
self.reader = None;
|
||||
return Ok(());
|
||||
}
|
||||
let request = resumed_read_request(&self.request, self.emitted)?;
|
||||
let transport = Arc::clone(&self.transport);
|
||||
self.resume = Some(tokio::spawn(async move { transport.open_read_fresh(request).await }));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for RetryingRemoteReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
loop {
|
||||
if let Some(resume) = self.resume.as_mut() {
|
||||
match Pin::new(resume).poll(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(Ok(reader))) => {
|
||||
self.resume = None;
|
||||
self.reader = Some(reader);
|
||||
}
|
||||
Poll::Ready(Ok(Err(error))) => {
|
||||
self.resume = None;
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.resume = None;
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(reader) = self.reader.as_mut() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
};
|
||||
let before = buf.filled().len();
|
||||
match Pin::new(reader).poll_read(cx, buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {
|
||||
let produced = buf.filled().len() - before;
|
||||
self.emitted = match self.emitted.checked_add(produced) {
|
||||
Some(emitted) => emitted,
|
||||
None => return Poll::Ready(Err(io::Error::other("remote read emitted byte count overflow"))),
|
||||
};
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => {
|
||||
self.retried = true;
|
||||
if let Err(resume_error) = self.start_resume() {
|
||||
return Poll::Ready(Err(resume_error));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChunkResumeFuture = tokio::task::JoinHandle<Result<Option<rustfs_rio::ChunkReaderBox>>>;
|
||||
|
||||
struct RetryingRemoteChunkReader {
|
||||
reader: Option<rustfs_rio::ChunkReaderBox>,
|
||||
transport: Arc<dyn InternodeDataTransport>,
|
||||
request: ReadStreamRequest,
|
||||
emitted: usize,
|
||||
retried: bool,
|
||||
resume: Option<ChunkResumeFuture>,
|
||||
}
|
||||
|
||||
impl RetryingRemoteChunkReader {
|
||||
fn new(reader: rustfs_rio::ChunkReaderBox, transport: Arc<dyn InternodeDataTransport>, request: ReadStreamRequest) -> Self {
|
||||
Self {
|
||||
reader: Some(reader),
|
||||
transport,
|
||||
request,
|
||||
emitted: 0,
|
||||
retried: false,
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_resume(&mut self) -> io::Result<()> {
|
||||
if self.request.length != 0 && self.emitted >= self.request.length {
|
||||
self.reader = None;
|
||||
return Ok(());
|
||||
}
|
||||
let request = resumed_read_request(&self.request, self.emitted)?;
|
||||
let transport = Arc::clone(&self.transport);
|
||||
self.resume = Some(tokio::spawn(async move { transport.open_read_chunks_fresh(request).await }));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for RetryingRemoteChunkReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
if buf.remaining() == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
match rustfs_rio::ChunkReader::poll_read_chunk(self.as_mut(), cx, buf.remaining()) {
|
||||
Poll::Ready(Ok(Some(chunk))) => {
|
||||
buf.put_slice(&chunk);
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Ok(None)) => Poll::Ready(Ok(())),
|
||||
Poll::Ready(Err(error)) => Poll::Ready(Err(error)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
|
||||
fn poll_read_chunk(mut self: Pin<&mut Self>, cx: &mut Context<'_>, max: usize) -> Poll<io::Result<Option<Bytes>>> {
|
||||
loop {
|
||||
if let Some(resume) = self.resume.as_mut() {
|
||||
match Pin::new(resume).poll(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(Ok(Some(reader)))) => {
|
||||
self.resume = None;
|
||||
self.reader = Some(reader);
|
||||
}
|
||||
Poll::Ready(Ok(Ok(None))) => {
|
||||
self.resume = None;
|
||||
self.reader = None;
|
||||
return Poll::Ready(Err(io::Error::other("remote resume transport did not provide a chunk reader")));
|
||||
}
|
||||
Poll::Ready(Ok(Err(error))) => {
|
||||
self.resume = None;
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.resume = None;
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(reader) = self.reader.as_mut() else {
|
||||
return Poll::Ready(Ok(None));
|
||||
};
|
||||
match rustfs_rio::ChunkReader::poll_read_chunk(Pin::new(reader.as_mut()), cx, max) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(Some(chunk))) => {
|
||||
self.emitted = match self.emitted.checked_add(chunk.len()) {
|
||||
Some(emitted) => emitted,
|
||||
None => return Poll::Ready(Err(io::Error::other("remote read emitted byte count overflow"))),
|
||||
};
|
||||
return Poll::Ready(Ok(Some(chunk)));
|
||||
}
|
||||
Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)),
|
||||
Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => {
|
||||
self.retried = true;
|
||||
if let Err(resume_error) = self.start_resume() {
|
||||
return Poll::Ready(Err(resume_error));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteDisk {
|
||||
pub id: Mutex<Option<Uuid>>,
|
||||
@@ -2469,7 +2696,7 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
let disk = self.disk_ref().await;
|
||||
let stall_timeout = get_object_disk_read_timeout();
|
||||
self.open_read_with_retry(ReadStreamRequest {
|
||||
let request = ReadStreamRequest {
|
||||
endpoint: self.endpoint.grid_host(),
|
||||
disk,
|
||||
volume: volume.to_string(),
|
||||
@@ -2477,8 +2704,9 @@ impl DiskAPI for RemoteDisk {
|
||||
offset,
|
||||
length,
|
||||
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
|
||||
})
|
||||
.await
|
||||
};
|
||||
let reader = self.open_read_with_retry(request.clone()).await?;
|
||||
Ok(Box::new(RetryingRemoteReader::new(reader, Arc::clone(&self.data_transport), request)))
|
||||
}
|
||||
|
||||
async fn read_file_stream_chunks(
|
||||
@@ -2493,7 +2721,7 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
let disk = self.disk_ref().await;
|
||||
let stall_timeout = get_object_disk_read_timeout();
|
||||
self.open_read_chunks_with_retry(ReadStreamRequest {
|
||||
let request = ReadStreamRequest {
|
||||
endpoint: self.endpoint.grid_host(),
|
||||
disk,
|
||||
volume: volume.to_string(),
|
||||
@@ -2501,8 +2729,12 @@ impl DiskAPI for RemoteDisk {
|
||||
offset,
|
||||
length,
|
||||
stall_timeout: (!stall_timeout.is_zero()).then_some(stall_timeout),
|
||||
})
|
||||
.await
|
||||
};
|
||||
let reader = self.open_read_chunks_with_retry(request.clone()).await?;
|
||||
Ok(reader.map(|reader| {
|
||||
Box::new(RetryingRemoteChunkReader::new(reader, Arc::clone(&self.data_transport), request))
|
||||
as rustfs_rio::ChunkReaderBox
|
||||
}))
|
||||
}
|
||||
|
||||
/// Buffered read for remote disks.
|
||||
@@ -4123,6 +4355,278 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ResumeReadStep {
|
||||
PartialThenReset(Vec<u8>),
|
||||
Data(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ResumeTransport {
|
||||
read_steps: Mutex<Vec<ResumeReadStep>>,
|
||||
chunk_steps: Mutex<Vec<ResumeReadStep>>,
|
||||
read_requests: Mutex<Vec<ReadStreamRequest>>,
|
||||
chunk_requests: Mutex<Vec<ReadStreamRequest>>,
|
||||
fresh_read_requests: Mutex<Vec<ReadStreamRequest>>,
|
||||
fresh_chunk_requests: Mutex<Vec<ReadStreamRequest>>,
|
||||
}
|
||||
|
||||
impl ResumeTransport {
|
||||
fn with_read_steps(read_steps: Vec<ResumeReadStep>) -> Self {
|
||||
Self {
|
||||
read_steps: Mutex::new(read_steps),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_chunk_steps(chunk_steps: Vec<ResumeReadStep>) -> Self {
|
||||
Self {
|
||||
chunk_steps: Mutex::new(chunk_steps),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChunkPartialThenErrorReader {
|
||||
data: Option<Bytes>,
|
||||
error: Option<io::Error>,
|
||||
}
|
||||
|
||||
impl rustfs_rio::ChunkReader for ChunkPartialThenErrorReader {
|
||||
fn poll_read_chunk(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, max: usize) -> Poll<io::Result<Option<Bytes>>> {
|
||||
if let Some(mut data) = self.data.take() {
|
||||
let take = data.len().min(max);
|
||||
let chunk = data.split_to(take);
|
||||
if !data.is_empty() {
|
||||
self.data = Some(data);
|
||||
}
|
||||
return Poll::Ready(Ok(Some(chunk)));
|
||||
}
|
||||
if let Some(error) = self.error.take() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
Poll::Ready(Ok(None))
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for ChunkPartialThenErrorReader {
|
||||
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, _buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Err(io::Error::other("chunk reader must use chunk handoff")))
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_step_reader(step: ResumeReadStep) -> FileReader {
|
||||
match step {
|
||||
ResumeReadStep::PartialThenReset(data) => Box::new(PartialThenErrorReader {
|
||||
cursor: Cursor::new(data),
|
||||
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
|
||||
}),
|
||||
ResumeReadStep::Data(data) => Box::new(Cursor::new(data)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_step_chunk_reader(step: ResumeReadStep) -> rustfs_rio::ChunkReaderBox {
|
||||
match step {
|
||||
ResumeReadStep::PartialThenReset(data) => Box::new(ChunkPartialThenErrorReader {
|
||||
data: Some(Bytes::from(data)),
|
||||
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
|
||||
}),
|
||||
ResumeReadStep::Data(data) => Box::new(ChunkPartialThenErrorReader {
|
||||
data: Some(Bytes::from(data)),
|
||||
error: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InternodeDataTransport for ResumeTransport {
|
||||
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader> {
|
||||
self.read_requests
|
||||
.lock()
|
||||
.expect("read request lock should not be poisoned")
|
||||
.push(request);
|
||||
let step = self
|
||||
.read_steps
|
||||
.lock()
|
||||
.expect("read steps lock should not be poisoned")
|
||||
.remove(0);
|
||||
Ok(resume_step_reader(step))
|
||||
}
|
||||
|
||||
async fn open_read_fresh(&self, request: ReadStreamRequest) -> Result<FileReader> {
|
||||
self.fresh_read_requests
|
||||
.lock()
|
||||
.expect("fresh read request lock should not be poisoned")
|
||||
.push(request.clone());
|
||||
self.open_read(request).await
|
||||
}
|
||||
|
||||
async fn open_read_chunks(&self, request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
self.chunk_requests
|
||||
.lock()
|
||||
.expect("chunk request lock should not be poisoned")
|
||||
.push(request);
|
||||
let step = self
|
||||
.chunk_steps
|
||||
.lock()
|
||||
.expect("chunk steps lock should not be poisoned")
|
||||
.remove(0);
|
||||
Ok(Some(resume_step_chunk_reader(step)))
|
||||
}
|
||||
|
||||
async fn open_read_chunks_fresh(&self, request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
self.fresh_chunk_requests
|
||||
.lock()
|
||||
.expect("fresh chunk request lock should not be poisoned")
|
||||
.push(request.clone());
|
||||
self.open_read_chunks(request).await
|
||||
}
|
||||
|
||||
async fn open_write(&self, _request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
panic!("open_write should not be used in remote read resume tests");
|
||||
}
|
||||
|
||||
async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result<FileReader> {
|
||||
panic!("open_walk_dir should not be used in remote read resume tests");
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"resume-test"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> InternodeDataTransportCapabilities {
|
||||
InternodeDataTransportCapabilities::tcp_http()
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_request(length: usize) -> ReadStreamRequest {
|
||||
ReadStreamRequest {
|
||||
endpoint: "http://remote".to_string(),
|
||||
disk: "disk".to_string(),
|
||||
volume: "volume".to_string(),
|
||||
path: "path".to_string(),
|
||||
offset: 7,
|
||||
length,
|
||||
stall_timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_reader_resumes_from_emitted_bytes_without_duplicates() {
|
||||
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Data(b"456789".to_vec())]));
|
||||
let request = resume_request(10);
|
||||
let reader = resume_step_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec()));
|
||||
let mut reader = RetryingRemoteReader::new(reader, transport.clone(), request);
|
||||
let mut output = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut output)
|
||||
.await
|
||||
.expect("one body reset should be resumed");
|
||||
|
||||
assert_eq!(output, b"0123456789");
|
||||
let requests = transport
|
||||
.read_requests
|
||||
.lock()
|
||||
.expect("read request lock should not be poisoned");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].offset, 11);
|
||||
assert_eq!(requests[0].length, 6);
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_read_requests
|
||||
.lock()
|
||||
.expect("fresh read request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_chunk_reader_resumes_from_emitted_bytes_without_duplicates() {
|
||||
let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::Data(b"456789".to_vec())]));
|
||||
let request = resume_request(10);
|
||||
let reader = resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec()));
|
||||
let mut reader = RetryingRemoteChunkReader::new(reader, transport.clone(), request);
|
||||
let mut output = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut output)
|
||||
.await
|
||||
.expect("chunk body reset should be resumed");
|
||||
|
||||
assert_eq!(output, b"0123456789");
|
||||
let requests = transport
|
||||
.chunk_requests
|
||||
.lock()
|
||||
.expect("chunk request lock should not be poisoned");
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].offset, 11);
|
||||
assert_eq!(requests[0].length, 6);
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_chunk_requests
|
||||
.lock()
|
||||
.expect("fresh chunk request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_reader_retries_at_most_once_and_preserves_non_retryable_errors() {
|
||||
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"456".to_vec())]));
|
||||
let mut reader = RetryingRemoteReader::new(
|
||||
resume_step_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec())),
|
||||
transport.clone(),
|
||||
resume_request(7),
|
||||
);
|
||||
let error = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("second reset must not retry");
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset);
|
||||
assert_eq!(
|
||||
transport
|
||||
.read_requests
|
||||
.lock()
|
||||
.expect("read request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let transport = Arc::new(ResumeTransport::default());
|
||||
let reader = PartialThenErrorReader {
|
||||
cursor: Cursor::new(b"data".to_vec()),
|
||||
error: Some(io::Error::new(std_io::ErrorKind::PermissionDenied, "permission denied")),
|
||||
};
|
||||
let mut reader = RetryingRemoteReader::new(Box::new(reader), transport.clone(), resume_request(4));
|
||||
let error = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("non-retryable errors must not retry");
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
transport
|
||||
.read_requests
|
||||
.lock()
|
||||
.expect("read request lock should not be poisoned")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_read_request_checks_large_offsets() {
|
||||
let request = ReadStreamRequest {
|
||||
offset: usize::MAX - 1,
|
||||
length: 0,
|
||||
..resume_request(0)
|
||||
};
|
||||
assert!(resumed_read_request(&request, 2).is_err());
|
||||
|
||||
let request = resume_request(4);
|
||||
assert!(resumed_read_request(&request, 5).is_err());
|
||||
}
|
||||
|
||||
fn init_tracing(filter_level: Level) {
|
||||
INIT.call_once(|| {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
|
||||
@@ -1355,7 +1355,7 @@ impl BucketUsageAccumulator {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let object_size = quota_object_size(object)?;
|
||||
let object_size = object.size.max(0) as u64;
|
||||
self.current_live_versions = self.current_live_versions.saturating_add(1);
|
||||
self.size_histogram.add(object_size);
|
||||
self.total_size = self.total_size.saturating_add(object_size);
|
||||
@@ -1385,31 +1385,6 @@ impl BucketUsageAccumulator {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quota_object_size(object: &ObjectInfo) -> Result<u64, Error> {
|
||||
let logical_size = u64::try_from(object.get_actual_size().map_err(Error::other)?).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
let persisted_part_size = if object.parts.is_empty() {
|
||||
u64::try_from(object.size).map_err(|_| Error::PartMissingOrCorrupt)?
|
||||
} else {
|
||||
object.parts.iter().try_fold(0_u64, |total, part| {
|
||||
// Compressed streaming objects persist -1 when the transformed
|
||||
// part size is unknown. The physical part size remains a valid
|
||||
// quota floor; reject only non-negative values that overflow.
|
||||
let actual_size = if part.actual_size < 0 {
|
||||
if object.is_compressed() {
|
||||
0
|
||||
} else {
|
||||
return Err(Error::PartMissingOrCorrupt);
|
||||
}
|
||||
} else {
|
||||
u64::try_from(part.actual_size).map_err(|_| Error::PartMissingOrCorrupt)?
|
||||
};
|
||||
let part_size = actual_size.max(u64::try_from(part.size).map_err(|_| Error::PartMissingOrCorrupt)?);
|
||||
total.checked_add(part_size).ok_or(Error::PartMissingOrCorrupt)
|
||||
})?
|
||||
};
|
||||
Ok(logical_size.max(persisted_part_size))
|
||||
}
|
||||
|
||||
type UsageVersionPage = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
|
||||
pub async fn compute_bucket_usage(store: Arc<ECStore>, bucket_name: &str) -> Result<BucketUsageInfo, Error> {
|
||||
@@ -3149,102 +3124,6 @@ mod tests {
|
||||
assert_eq!(usage.object_versions_histogram.get("BETWEEN_1000_AND_10000"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_usage_uses_the_larger_of_logical_and_physical_size() {
|
||||
let mut metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut metadata,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
"klauspost/compress/s2".to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "4096".to_string());
|
||||
let object = ObjectInfo {
|
||||
name: "compressed".to_string(),
|
||||
size: 128,
|
||||
user_defined: Arc::new(metadata),
|
||||
..Default::default()
|
||||
};
|
||||
let mut usage = BucketUsageAccumulator::default();
|
||||
usage
|
||||
.record("bucket", &object)
|
||||
.expect("valid compressed metadata should be counted");
|
||||
assert_eq!(usage.finish().size, 4096);
|
||||
|
||||
let mut framed_metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut framed_metadata,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
"klauspost/compress/s2".to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut framed_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
|
||||
let framed = ObjectInfo {
|
||||
name: "framed".to_string(),
|
||||
size: 17,
|
||||
user_defined: Arc::new(framed_metadata),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(quota_object_size(&framed).expect("physical framing must remain quota-accounted"), 17);
|
||||
|
||||
let legacy_compressed_part = ObjectInfo {
|
||||
name: "legacy-compressed-part".to_string(),
|
||||
size: 1,
|
||||
user_defined: Arc::new((*framed.user_defined).clone()),
|
||||
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
||||
size: 1,
|
||||
actual_size: -1,
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
quota_object_size(&legacy_compressed_part).expect("unknown compressed part size is a valid sentinel"),
|
||||
1
|
||||
);
|
||||
|
||||
let uncompressed_negative_part = ObjectInfo {
|
||||
name: "uncompressed-negative-part".to_string(),
|
||||
size: 1,
|
||||
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
||||
size: 1,
|
||||
actual_size: -1,
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(quota_object_size(&uncompressed_negative_part), Err(Error::PartMissingOrCorrupt)));
|
||||
|
||||
let mut corrupt_metadata = (*object.user_defined).clone();
|
||||
rustfs_utils::http::insert_str(&mut corrupt_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "-1".to_string());
|
||||
let corrupt = ObjectInfo {
|
||||
user_defined: Arc::new(corrupt_metadata),
|
||||
..object
|
||||
};
|
||||
assert!(matches!(quota_object_size(&corrupt), Err(Error::PartMissingOrCorrupt)));
|
||||
|
||||
let mut poisoned_metadata = HashMap::new();
|
||||
rustfs_utils::http::insert_str(
|
||||
&mut poisoned_metadata,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
"klauspost/compress/s2".to_string(),
|
||||
);
|
||||
rustfs_utils::http::insert_str(&mut poisoned_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
|
||||
let poisoned = ObjectInfo {
|
||||
name: "legacy-swift-metadata".to_string(),
|
||||
size: 4096,
|
||||
user_defined: Arc::new(poisoned_metadata),
|
||||
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
||||
size: 4096,
|
||||
actual_size: 4096,
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
quota_object_size(&poisoned).expect("persisted part accounting must bound legacy user metadata"),
|
||||
4096
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn live_bucket_usage_refreshes_are_coalesced_only_while_in_flight() {
|
||||
|
||||
@@ -27,18 +27,17 @@ use crate::disk::{
|
||||
BUCKET_META_PREFIX, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
|
||||
CHECK_PART_VOLUME_NOT_FOUND, CheckPartsResp, ConditionalFileUpdate, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskInfo,
|
||||
DiskInfoOptions, DiskLocation, DiskMetrics, FileInfoVersions, FileReader, FileWriter, MmapCopyStageMetrics, OldCurrentSize,
|
||||
PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET,
|
||||
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP,
|
||||
SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, conv_part_err_to_int,
|
||||
PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction, RUSTFS_META_BUCKET,
|
||||
RUSTFS_META_TMP_BUCKET, RUSTFS_META_TMP_DELETED_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
||||
STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
conv_part_err_to_int,
|
||||
endpoint::Endpoint,
|
||||
error::{DiskError, Error, FileAccessDeniedWithContext, Result},
|
||||
error_conv::{to_access_error, to_file_error, to_unformatted_disk_error, to_volume_error},
|
||||
format::FormatV3,
|
||||
fs::{O_APPEND, O_CREATE, O_RDONLY, O_TRUNC, O_WRONLY, access, lstat, lstat_std, remove, remove_all_std, remove_std, rename},
|
||||
is_quota_mutation_fence_path, os,
|
||||
os,
|
||||
os::{check_path_length, is_dir_not_empty_error, is_empty_dir, is_root_disk, rename_all, rename_all_ignore_missing_source},
|
||||
quota_mutation_fence_path,
|
||||
};
|
||||
use crate::erasure::coding::{self, bitrot_verify};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -61,7 +60,9 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Debug;
|
||||
use std::io::{Error as IoError, SeekFrom};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
@@ -4755,25 +4756,6 @@ struct SnapshotLeaseEntry {
|
||||
tokens: HashSet<SnapshotLeaseToken>,
|
||||
pending_delete: Option<DeleteOptions>,
|
||||
deleting: bool,
|
||||
mutation_fence: Option<Arc<QuotaMutationFenceState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct QuotaMutationFenceState {
|
||||
revoked: AtomicBool,
|
||||
running: AtomicUsize,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
struct QuotaMutationFenceClaim {
|
||||
state: Arc<QuotaMutationFenceState>,
|
||||
}
|
||||
|
||||
impl Drop for QuotaMutationFenceClaim {
|
||||
fn drop(&mut self) {
|
||||
self.state.running.fetch_sub(1, Ordering::AcqRel);
|
||||
self.state.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -7338,34 +7320,6 @@ fn normalize_path_components(path: impl AsRef<Path>) -> PathBuf {
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
async fn claim_quota_mutation_fence(
|
||||
&self,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
token: SnapshotLeaseToken,
|
||||
) -> Result<Arc<QuotaMutationFenceClaim>> {
|
||||
let key = SnapshotLeaseKey {
|
||||
volume: RUSTFS_META_BUCKET.to_string(),
|
||||
path: quota_mutation_fence_path(volume, path),
|
||||
};
|
||||
let state = {
|
||||
let registry = self.snapshot_leases.lock().await;
|
||||
let entry = registry.entries.get(&key).ok_or(DiskError::FileNotFound)?;
|
||||
let state = entry.mutation_fence.as_ref().ok_or(DiskError::FileNotFound)?;
|
||||
if !entry.tokens.contains(&token) || state.revoked.load(Ordering::Acquire) {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
state.running.fetch_add(1, Ordering::AcqRel);
|
||||
Arc::clone(state)
|
||||
};
|
||||
if state.revoked.load(Ordering::Acquire) {
|
||||
state.running.fetch_sub(1, Ordering::AcqRel);
|
||||
state.notify.notify_waiters();
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
Ok(Arc::new(QuotaMutationFenceClaim { state }))
|
||||
}
|
||||
|
||||
async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> {
|
||||
let path = format!("{object}/{data_dir}");
|
||||
let data_path = self.io_get_object_path(volume, &path)?;
|
||||
@@ -8699,30 +8653,7 @@ impl DiskAPI for LocalDisk {
|
||||
// optimistic; this lease establishes the local commit/delete order and
|
||||
// remains owned by any blocking syscall that outlives async cancellation.
|
||||
let destination_object_path = self.io_get_object_path(dst_volume, dst_path)?;
|
||||
let quota_fence_token =
|
||||
match rustfs_utils::http::metadata_compat::get_consistent_str(&fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX) {
|
||||
Some(value) => {
|
||||
let token = Uuid::parse_str(value).map_err(|_| DiskError::FileCorrupt)?;
|
||||
Some(SnapshotLeaseToken::from_slice(token.as_bytes())?)
|
||||
}
|
||||
None if rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
) =>
|
||||
{
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
rustfs_utils::http::metadata_compat::remove_str(&mut fi.metadata, QUOTA_MUTATION_FENCE_METADATA_SUFFIX);
|
||||
let quota_fence_claim = match quota_fence_token {
|
||||
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
|
||||
None => None,
|
||||
};
|
||||
let mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, dst_volume, &destination_object_path).await;
|
||||
if let Some(claim) = quota_fence_claim {
|
||||
mutation_lease.attach_external_guard(claim);
|
||||
}
|
||||
if fi.is_legacy_indexed_delete_marker() {
|
||||
fi.erasure.index = 0;
|
||||
}
|
||||
@@ -9712,26 +9643,11 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
let key = SnapshotLeaseKey {
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
};
|
||||
if volume == RUSTFS_META_BUCKET && is_quota_mutation_fence_path(path) {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let entry = registry.entries.entry(key).or_default();
|
||||
let state = entry
|
||||
.mutation_fence
|
||||
.get_or_insert_with(|| Arc::new(QuotaMutationFenceState::default()));
|
||||
if state.revoked.load(Ordering::Acquire) {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
let token = SnapshotLeaseToken::new();
|
||||
entry.tokens.insert(token);
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let file_path = self.io_get_object_path(volume, path)?;
|
||||
let _mutation_lease = os::acquire_rename_data_mutation_lease(&self.root, volume, &file_path).await;
|
||||
let token = {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
if registry.entries.get(&key).is_some_and(|entry| entry.deleting) {
|
||||
@@ -9759,48 +9675,6 @@ impl DiskAPI for LocalDisk {
|
||||
volume: volume.to_string(),
|
||||
path: path.to_string(),
|
||||
};
|
||||
if volume == RUSTFS_META_BUCKET && is_quota_mutation_fence_path(path) {
|
||||
if !token.is_revoke_all() {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let Some(entry) = registry.entries.get_mut(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
entry.tokens.remove(&token);
|
||||
let removable = entry.tokens.is_empty()
|
||||
&& entry
|
||||
.mutation_fence
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.running.load(Ordering::Acquire) == 0);
|
||||
if removable {
|
||||
registry.entries.remove(&key);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let state = {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let Some(entry) = registry.entries.get_mut(&key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(state) = entry.mutation_fence.as_ref().cloned() else {
|
||||
registry.entries.remove(&key);
|
||||
return Ok(());
|
||||
};
|
||||
state.revoked.store(true, Ordering::Release);
|
||||
entry.tokens.clear();
|
||||
state
|
||||
};
|
||||
loop {
|
||||
let notified = state.notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if state.running.load(Ordering::Acquire) == 0 {
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
self.snapshot_leases.lock().await.entries.remove(&key);
|
||||
return Ok(());
|
||||
}
|
||||
let opts = {
|
||||
let mut registry = self.snapshot_leases.lock().await;
|
||||
let Some(entry) = registry.entries.get_mut(&key) else {
|
||||
@@ -19015,48 +18889,6 @@ mod test {
|
||||
assert!(matches!(disk.read_all(volume, &first_part).await, Err(DiskError::FileNotFound)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_mutation_fence_revoke_waits_for_active_claim_and_rejects_late_claims() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let root_dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(root_dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
|
||||
let bucket = "quota-fence-volume";
|
||||
let object = "object";
|
||||
let fence_path = quota_mutation_fence_path(bucket, object);
|
||||
let token = disk
|
||||
.acquire_snapshot_lease(RUSTFS_META_BUCKET, &fence_path)
|
||||
.await
|
||||
.expect("quota mutation token should be prepared");
|
||||
let claim = disk
|
||||
.claim_quota_mutation_fence(bucket, object, token)
|
||||
.await
|
||||
.expect("prepared token should be claimable");
|
||||
|
||||
let release_disk = Arc::clone(&disk);
|
||||
let mut release = tokio::spawn(async move {
|
||||
release_disk
|
||||
.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, SnapshotLeaseToken::revoke_all())
|
||||
.await
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut release).await.is_err(),
|
||||
"revoke must wait until an already claimed mutation has finished"
|
||||
);
|
||||
|
||||
drop(claim);
|
||||
tokio::time::timeout(Duration::from_secs(1), release)
|
||||
.await
|
||||
.expect("revoke should wake after the final claim drops")
|
||||
.expect("revoke task should not panic")
|
||||
.expect("revoke should succeed");
|
||||
assert!(matches!(
|
||||
disk.claim_quota_mutation_fence(bucket, object, token).await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_version_keeps_later_part_until_snapshot_release() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -72,28 +72,6 @@ use time::OffsetDateTime;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use uuid::Uuid;
|
||||
|
||||
const QUOTA_MUTATION_FENCE_PREFIX: &str = "tmp/quota-mutation-fences/";
|
||||
pub(crate) const QUOTA_MUTATION_FENCE_METADATA_SUFFIX: &str = "quota-mutation-fence-token";
|
||||
|
||||
pub(crate) fn quota_mutation_fence_path(bucket: &str, object: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut input = Vec::with_capacity(bucket.len() + object.len() + 1);
|
||||
input.extend_from_slice(bucket.as_bytes());
|
||||
input.push(0);
|
||||
input.extend_from_slice(object.as_bytes());
|
||||
let digest = Sha256::digest(input);
|
||||
format!(
|
||||
"{QUOTA_MUTATION_FENCE_PREFIX}{}",
|
||||
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_quota_mutation_fence_path(path: &str) -> bool {
|
||||
path.strip_prefix(QUOTA_MUTATION_FENCE_PREFIX)
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
pub type DiskStore = Arc<Disk>;
|
||||
|
||||
pub type FileReader = Box<dyn AsyncRead + Send + Sync + Unpin>;
|
||||
@@ -118,20 +96,6 @@ impl SnapshotLeaseToken {
|
||||
pub fn as_bytes(&self) -> &[u8; 16] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn as_uuid(self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn revoke_all() -> Self {
|
||||
Self(Uuid::nil())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn is_revoke_all(self) -> bool {
|
||||
self.0.is_nil()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SnapshotLeaseToken {
|
||||
|
||||
@@ -306,20 +306,12 @@ fn disk_namespace_mutation_lock(path: &Path) -> Arc<NamespaceMutationLock> {
|
||||
pub(crate) struct NamespaceMutationLease {
|
||||
_namespace_guard: OwnedMutexGuard<()>,
|
||||
_volume_guard: Option<OwnedRwLockReadGuard<()>>,
|
||||
external_guard: Mutex<Option<Arc<dyn Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl NamespaceMutationLease {
|
||||
pub(crate) fn attach_external_guard(&self, guard: Arc<dyn Send + Sync>) {
|
||||
*self.external_guard.lock() = Some(guard);
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> {
|
||||
Arc::new(NamespaceMutationLease {
|
||||
_namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await,
|
||||
_volume_guard: None,
|
||||
external_guard: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -335,7 +327,6 @@ pub(crate) async fn acquire_rename_data_mutation_lease(
|
||||
Arc::new(NamespaceMutationLease {
|
||||
_namespace_guard: namespace_guard,
|
||||
_volume_guard: Some(volume_guard),
|
||||
external_guard: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,14 +44,12 @@ const CONSECUTIVE_FAILURE_THRESHOLD: u32 = 3;
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
|
||||
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
|
||||
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
|
||||
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
|
||||
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 1;
|
||||
|
||||
/// Cached result from the last successful admin call to a peer.
|
||||
struct PeerAdminCache {
|
||||
@@ -97,15 +95,15 @@ lazy_static! {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FleetCapabilityProof {
|
||||
struct RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: String,
|
||||
peer_epochs: Arc<BTreeMap<String, Uuid>>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl FleetCapabilityProof {
|
||||
fn token(&self) -> FleetCapabilityProofToken {
|
||||
FleetCapabilityProofToken {
|
||||
impl RemoteVersionStateFleetProof {
|
||||
fn token(&self) -> RemoteVersionStateFleetProofToken {
|
||||
RemoteVersionStateFleetProofToken {
|
||||
topology_fingerprint: self.topology_fingerprint.clone(),
|
||||
peer_epochs: self.peer_epochs.clone(),
|
||||
}
|
||||
@@ -113,41 +111,37 @@ impl FleetCapabilityProof {
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct FleetCapabilityProofToken {
|
||||
pub(crate) struct RemoteVersionStateFleetProofToken {
|
||||
topology_fingerprint: String,
|
||||
peer_epochs: Arc<BTreeMap<String, Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FleetCapabilityProofState {
|
||||
proof: Option<FleetCapabilityProof>,
|
||||
struct RemoteVersionStateFleetProofState {
|
||||
proof: Option<RemoteVersionStateFleetProof>,
|
||||
topology_conflict: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct RemoteVersionStateFleetProofToken(FleetCapabilityProofToken);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CrossPoolFenceFleetProofToken(FleetCapabilityProofToken);
|
||||
|
||||
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static CROSS_POOL_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<RemoteVersionStateFleetProofState>> = OnceLock::new();
|
||||
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
|
||||
|
||||
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||
CROSS_POOL_FENCE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||
fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<RemoteVersionStateFleetProofState> {
|
||||
REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()))
|
||||
}
|
||||
|
||||
fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||
REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||
fn replace_remote_version_state_fleet_proof(proof: Option<RemoteVersionStateFleetProof>) {
|
||||
replace_remote_version_state_fleet_proof_in(remote_version_state_fleet_proof_slot(), proof);
|
||||
}
|
||||
|
||||
fn replace_fleet_capability_proof(slot: &std::sync::RwLock<FleetCapabilityProofState>, proof: Option<FleetCapabilityProof>) {
|
||||
fn replace_remote_version_state_fleet_proof_in(
|
||||
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
|
||||
proof: Option<RemoteVersionStateFleetProof>,
|
||||
) {
|
||||
slot.write().unwrap_or_else(std::sync::PoisonError::into_inner).proof = proof;
|
||||
}
|
||||
|
||||
fn publish_fleet_capability_probe_result(
|
||||
slot: &std::sync::RwLock<FleetCapabilityProofState>,
|
||||
fn publish_remote_version_state_probe_result(
|
||||
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
|
||||
topology_fingerprint: &str,
|
||||
result: Result<BTreeMap<String, Uuid>>,
|
||||
observed_at: Instant,
|
||||
@@ -161,7 +155,7 @@ fn publish_fleet_capability_probe_result(
|
||||
.filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs)
|
||||
.map(|proof| Arc::clone(&proof.peer_epochs))
|
||||
.unwrap_or_else(|| Arc::new(peer_epochs));
|
||||
state.proof = Some(FleetCapabilityProof {
|
||||
state.proof = Some(RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: topology_fingerprint.to_string(),
|
||||
peer_epochs,
|
||||
expires_at: observed_at + REMOTE_VERSION_STATE_PROOF_TTL,
|
||||
@@ -169,7 +163,7 @@ fn publish_fleet_capability_probe_result(
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
replace_fleet_capability_proof(slot, None);
|
||||
replace_remote_version_state_fleet_proof_in(slot, None);
|
||||
Some(err)
|
||||
}
|
||||
}
|
||||
@@ -180,60 +174,27 @@ pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option<RemoteVersion
|
||||
let state = remote_version_state_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(RemoteVersionStateFleetProofToken)
|
||||
acquire_remote_version_state_fleet_proof_from(&state, expected_topology, Instant::now())
|
||||
}
|
||||
|
||||
fn acquire_fleet_capability_proof_from(
|
||||
state: &FleetCapabilityProofState,
|
||||
fn acquire_remote_version_state_fleet_proof_from(
|
||||
state: &RemoteVersionStateFleetProofState,
|
||||
expected_topology: &str,
|
||||
now: Instant,
|
||||
) -> Option<FleetCapabilityProofToken> {
|
||||
if state.topology_conflict || !fleet_capability_proof_valid_at(state.proof.as_ref(), expected_topology, now) {
|
||||
) -> Option<RemoteVersionStateFleetProofToken> {
|
||||
if state.topology_conflict || !remote_version_state_fleet_proof_valid_at(state.proof.as_ref(), expected_topology, now) {
|
||||
return None;
|
||||
}
|
||||
state.proof.as_ref().map(FleetCapabilityProof::token)
|
||||
state.proof.as_ref().map(RemoteVersionStateFleetProof::token)
|
||||
}
|
||||
|
||||
pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStateFleetProofToken) -> bool {
|
||||
fleet_capability_proof_matches(remote_version_state_fleet_proof_slot(), &proof.0)
|
||||
}
|
||||
|
||||
pub fn acquire_cross_pool_fence_fleet_proof() -> Option<CrossPoolFenceFleetProofToken> {
|
||||
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
|
||||
let state = cross_pool_fence_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
acquire_fleet_capability_proof_from(&state, expected_topology, Instant::now()).map(CrossPoolFenceFleetProofToken)
|
||||
}
|
||||
|
||||
pub fn cross_pool_fence_fleet_proof_matches(proof: &CrossPoolFenceFleetProofToken) -> bool {
|
||||
fleet_capability_proof_matches(cross_pool_fence_fleet_proof_slot(), &proof.0)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub fn rotate_cross_pool_fence_fleet_proof_for_test() -> bool {
|
||||
let mut state = cross_pool_fence_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(current) = state.proof.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
state.proof = Some(FleetCapabilityProof {
|
||||
topology_fingerprint: current.topology_fingerprint.clone(),
|
||||
peer_epochs: Arc::new(current.peer_epochs.as_ref().clone()),
|
||||
expires_at: current.expires_at,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn fleet_capability_proof_matches(
|
||||
slot: &std::sync::RwLock<FleetCapabilityProofState>,
|
||||
proof: &FleetCapabilityProofToken,
|
||||
) -> bool {
|
||||
let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else {
|
||||
return false;
|
||||
};
|
||||
let state = slot.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let state = remote_version_state_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if state.topology_conflict {
|
||||
return false;
|
||||
}
|
||||
@@ -245,17 +206,13 @@ fn fleet_capability_proof_matches(
|
||||
})
|
||||
}
|
||||
|
||||
fn fleet_capability_proof_valid_at(proof: Option<&FleetCapabilityProof>, expected_topology: &str, now: Instant) -> bool {
|
||||
proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct RemoteVersionStateFleetProofGuard;
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for RemoteVersionStateFleetProofGuard {
|
||||
fn drop(&mut self) {
|
||||
replace_fleet_capability_proof(remote_version_state_fleet_proof_slot(), None);
|
||||
replace_remote_version_state_fleet_proof(None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +227,7 @@ pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerp
|
||||
Err(_) => panic!("remote version state test topology is already bound to another fingerprint"),
|
||||
}
|
||||
let peer_epochs = BTreeMap::new();
|
||||
if let Some(err) = publish_fleet_capability_probe_result(
|
||||
if let Some(err) = publish_remote_version_state_probe_result(
|
||||
remote_version_state_fleet_proof_slot(),
|
||||
topology_fingerprint,
|
||||
Ok(peer_epochs),
|
||||
@@ -281,6 +238,14 @@ pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerp
|
||||
RemoteVersionStateFleetProofGuard
|
||||
}
|
||||
|
||||
fn remote_version_state_fleet_proof_valid_at(
|
||||
proof: Option<&RemoteVersionStateFleetProof>,
|
||||
expected_topology: &str,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at)
|
||||
}
|
||||
|
||||
fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, peer: String, epoch: Uuid) -> Result<()> {
|
||||
if epoch.is_nil() || peer_epochs.values().any(|existing| *existing == epoch) || peer_epochs.insert(peer, epoch).is_some() {
|
||||
return Err(Error::other("remote version state capability peer identity is invalid"));
|
||||
@@ -291,11 +256,11 @@ fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, pe
|
||||
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
|
||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
|
||||
for slot in [remote_version_state_fleet_proof_slot(), cross_pool_fence_fleet_proof_slot()] {
|
||||
let mut state = slot.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.topology_conflict = true;
|
||||
state.proof = None;
|
||||
}
|
||||
let mut state = remote_version_state_fleet_proof_slot()
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.topology_conflict = true;
|
||||
state.proof = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -316,23 +281,13 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
}
|
||||
None => Err(Error::other("remote version state fleet capability notification system is unavailable")),
|
||||
};
|
||||
let fence_result = match get_global_notification_sys() {
|
||||
Some(notification_sys) => timeout(
|
||||
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
||||
notification_sys.probe_cross_pool_fence_fleet(&topology_fingerprint),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
|
||||
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
|
||||
};
|
||||
let topology_conflict = remote_version_state_fleet_proof_slot()
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.topology_conflict;
|
||||
if topology_conflict {
|
||||
replace_fleet_capability_proof(remote_version_state_fleet_proof_slot(), None);
|
||||
replace_fleet_capability_proof(cross_pool_fence_fleet_proof_slot(), None);
|
||||
} else if let Some(err) = publish_fleet_capability_probe_result(
|
||||
replace_remote_version_state_fleet_proof(None);
|
||||
} else if let Some(err) = publish_remote_version_state_probe_result(
|
||||
remote_version_state_fleet_proof_slot(),
|
||||
&topology_fingerprint,
|
||||
result,
|
||||
@@ -340,24 +295,6 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
||||
) {
|
||||
debug!(error = %err, "remote version state fleet capability probe failed closed");
|
||||
}
|
||||
if !topology_conflict
|
||||
&& let Some(err) = publish_fleet_capability_probe_result(
|
||||
cross_pool_fence_fleet_proof_slot(),
|
||||
&topology_fingerprint,
|
||||
fence_result,
|
||||
Instant::now(),
|
||||
)
|
||||
{
|
||||
debug!(
|
||||
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
capability = "cross_pool_fence_v1",
|
||||
state = "failed_closed",
|
||||
error = %err,
|
||||
"notification capability probe"
|
||||
);
|
||||
}
|
||||
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
|
||||
}
|
||||
});
|
||||
@@ -425,27 +362,6 @@ impl NotificationSys {
|
||||
}
|
||||
Ok(peer_epochs)
|
||||
}
|
||||
|
||||
async fn probe_cross_pool_fence_fleet(&self, topology_fingerprint: &str) -> Result<BTreeMap<String, Uuid>> {
|
||||
if self.peer_clients.len() != self.peer_topology_hosts.len() {
|
||||
return Err(Error::other("cross-pool fence capability fleet membership is incomplete"));
|
||||
}
|
||||
let probes = self.peer_clients.iter().map(|client| async {
|
||||
let client = client
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::other("cross-pool fence capability peer is unreachable"))?;
|
||||
client.probe_cross_pool_fence(topology_fingerprint.to_string()).await
|
||||
});
|
||||
let mut peer_epochs = BTreeMap::new();
|
||||
for result in join_all(probes).await {
|
||||
let (peer, version, epoch) = result?;
|
||||
if version < CROSS_POOL_FENCE_SUPPORTED_VERSION {
|
||||
return Err(Error::other("cross-pool fence capability version is unsupported"));
|
||||
}
|
||||
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
|
||||
}
|
||||
Ok(peer_epochs)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotificationPeerErr {
|
||||
@@ -2261,16 +2177,16 @@ mod tests {
|
||||
let now = Instant::now();
|
||||
let mut peer_epochs = BTreeMap::new();
|
||||
peer_epochs.insert("peer-a".to_string(), Uuid::new_v4());
|
||||
let proof = FleetCapabilityProof {
|
||||
let proof = RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(peer_epochs),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
};
|
||||
|
||||
assert!(fleet_capability_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
assert!(!fleet_capability_proof_valid_at(Some(&proof), "topology-b", now));
|
||||
assert!(!fleet_capability_proof_valid_at(Some(&proof), "topology-a", proof.expires_at));
|
||||
assert!(!fleet_capability_proof_valid_at(None, "topology-a", now));
|
||||
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-b", now));
|
||||
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", proof.expires_at));
|
||||
assert!(!remote_version_state_fleet_proof_valid_at(None, "topology-a", now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2284,25 +2200,25 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_accepts_single_node_membership() {
|
||||
let now = Instant::now();
|
||||
let proof = FleetCapabilityProof {
|
||||
let proof = RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(BTreeMap::new()),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
};
|
||||
|
||||
assert!(fleet_capability_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_token_changes_with_process_epoch() {
|
||||
let now = Instant::now();
|
||||
let proof = FleetCapabilityProof {
|
||||
let proof = RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
};
|
||||
let captured = proof.token();
|
||||
let restarted = FleetCapabilityProof {
|
||||
let restarted = RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: proof.topology_fingerprint.clone(),
|
||||
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
|
||||
expires_at: proof.expires_at,
|
||||
@@ -2313,11 +2229,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_renewal_preserves_only_same_epoch_token() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default());
|
||||
let now = Instant::now();
|
||||
let epoch = Uuid::new_v4();
|
||||
let peers = BTreeMap::from([("peer-a".to_string(), epoch)]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none());
|
||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none());
|
||||
let original = slot
|
||||
.read()
|
||||
.expect("proof slot should not poison")
|
||||
@@ -2326,7 +2242,9 @@ mod tests {
|
||||
.expect("successful probe should publish proof")
|
||||
.token();
|
||||
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none());
|
||||
assert!(
|
||||
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none()
|
||||
);
|
||||
let renewed = slot
|
||||
.read()
|
||||
.expect("proof slot should not poison")
|
||||
@@ -2338,7 +2256,8 @@ mod tests {
|
||||
|
||||
let restarted = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||
assert!(
|
||||
publish_fleet_capability_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2)).is_none()
|
||||
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2))
|
||||
.is_none()
|
||||
);
|
||||
let replaced = slot
|
||||
.read()
|
||||
@@ -2353,18 +2272,18 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() {
|
||||
let now = Instant::now();
|
||||
let mut state = FleetCapabilityProofState {
|
||||
proof: Some(FleetCapabilityProof {
|
||||
let mut state = RemoteVersionStateFleetProofState {
|
||||
proof: Some(RemoteVersionStateFleetProof {
|
||||
topology_fingerprint: "topology-a".to_string(),
|
||||
peer_epochs: Arc::new(BTreeMap::new()),
|
||||
expires_at: now + Duration::from_secs(1),
|
||||
}),
|
||||
topology_conflict: false,
|
||||
};
|
||||
assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_some());
|
||||
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_some());
|
||||
|
||||
state.topology_conflict = true;
|
||||
assert!(acquire_fleet_capability_proof_from(&state, "topology-a", now).is_none());
|
||||
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2380,19 +2299,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_fleet_probe_failure_revokes_previous_proof() {
|
||||
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default());
|
||||
let now = Instant::now();
|
||||
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(slot.read().expect("proof slot should not poison").proof.is_some());
|
||||
|
||||
assert!(
|
||||
publish_fleet_capability_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some()
|
||||
publish_remote_version_state_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some()
|
||||
);
|
||||
assert!(slot.read().expect("proof slot should not poison").proof.is_none());
|
||||
|
||||
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
|
||||
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
||||
assert!(slot.read().expect("proof slot should not poison").proof.is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ impl MetadataQuorumAccumulator {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn default_write_quorum(&self) -> usize {
|
||||
pub(in crate::set_disk) fn default_write_quorum(&self) -> usize {
|
||||
if self.default_parity_count == 0 || self.default_parity_count >= self.total_disks {
|
||||
return self.total_disks;
|
||||
}
|
||||
@@ -3016,7 +3016,7 @@ impl SetDisks {
|
||||
self.set_drive_count - self.default_parity_count
|
||||
}
|
||||
|
||||
pub(crate) fn default_write_quorum(&self) -> usize {
|
||||
pub(in crate::set_disk) fn default_write_quorum(&self) -> usize {
|
||||
let mut data_count = self.set_drive_count - self.default_parity_count;
|
||||
if data_count == self.default_parity_count {
|
||||
data_count += 1
|
||||
@@ -3025,89 +3025,6 @@ impl SetDisks {
|
||||
data_count
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) async fn prepare_quota_mutation_fences(
|
||||
disks: &[Option<DiskStore>],
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
write_quorum: usize,
|
||||
) -> crate::error::Result<(Vec<Option<DiskStore>>, Vec<Option<SnapshotLeaseToken>>)> {
|
||||
let fence_path = crate::disk::quota_mutation_fence_path(bucket, object);
|
||||
let results = join_all(disks.iter().map(|disk| {
|
||||
let disk = disk.clone();
|
||||
let fence_path = fence_path.clone();
|
||||
async move {
|
||||
let disk = disk?;
|
||||
match disk.acquire_snapshot_lease(RUSTFS_META_BUCKET, &fence_path).await {
|
||||
Ok(token) => Some((disk, token)),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
if results.iter().flatten().count() < write_quorum {
|
||||
for (disk, token) in results.iter().flatten() {
|
||||
let _ = disk.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, *token).await;
|
||||
}
|
||||
return Err(StorageError::ErasureWriteQuorum);
|
||||
}
|
||||
let mut fenced_disks = Vec::with_capacity(results.len());
|
||||
let mut tokens = Vec::with_capacity(results.len());
|
||||
for result in results {
|
||||
match result {
|
||||
Some((disk, token)) => {
|
||||
fenced_disks.push(Some(disk));
|
||||
tokens.push(Some(token));
|
||||
}
|
||||
None => {
|
||||
fenced_disks.push(None);
|
||||
tokens.push(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((fenced_disks, tokens))
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) async fn release_quota_mutation_fences(
|
||||
disks: &[Option<DiskStore>],
|
||||
tokens: &[Option<SnapshotLeaseToken>],
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
write_quorum: usize,
|
||||
) -> crate::error::Result<()> {
|
||||
let fence_path = crate::disk::quota_mutation_fence_path(bucket, object);
|
||||
let results = join_all(disks.iter().zip(tokens).filter_map(|(disk, token)| {
|
||||
let disk = disk.as_ref()?.clone();
|
||||
let token = (*token)?;
|
||||
let fence_path = fence_path.clone();
|
||||
Some(async move { disk.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, token).await })
|
||||
}))
|
||||
.await;
|
||||
if results.iter().filter(|result| result.is_ok()).count() < write_quorum {
|
||||
return Err(StorageError::ErasureWriteQuorum);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) async fn abort_quota_reservation_after_fence(
|
||||
reservation: crate::bucket::quota::reservation::QuotaReservation,
|
||||
disks: &[Option<DiskStore>],
|
||||
tokens: &[Option<SnapshotLeaseToken>],
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
write_quorum: usize,
|
||||
fenced: bool,
|
||||
) {
|
||||
let safe_to_abort = !fenced
|
||||
|| Self::release_quota_mutation_fences(disks, tokens, bucket, object, write_quorum)
|
||||
.await
|
||||
.is_ok();
|
||||
if safe_to_abort {
|
||||
reservation.abort().await;
|
||||
} else {
|
||||
reservation.defer_after_fence();
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(disks, file_infos))]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(in crate::set_disk) async fn rename_data(
|
||||
|
||||
@@ -719,8 +719,8 @@ pub(crate) use core::io_primitives::disk_call_counters;
|
||||
mod ctx;
|
||||
mod metadata;
|
||||
mod ops;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
|
||||
#[cfg(test)]
|
||||
pub(crate) use ops::multipart::{MultipartCommitBarrier, MultipartCommitPause};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
|
||||
pub(crate) use ops::object::body_cache_plaintext_len;
|
||||
|
||||
@@ -27,12 +27,11 @@ use super::object::{
|
||||
object_transaction_fencing_requested, old_data_cleanup_receipt_path, read_object_transaction_epoch_fence,
|
||||
verify_object_transaction_epoch_fence,
|
||||
};
|
||||
use crate::bucket::quota::reservation;
|
||||
use crate::crash_inject::{self, CrashPoint};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use futures::{StreamExt, stream};
|
||||
use std::future::Future;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinSet;
|
||||
@@ -62,21 +61,20 @@ impl StaleMultipartCleanupGuard {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MultipartCommitPause {
|
||||
pub(crate) enum MultipartCommitPause {
|
||||
PutPartBeforeLockAcquire,
|
||||
PutPartBeforeLockLost,
|
||||
PutPartAfterRename,
|
||||
BeforeLockLost,
|
||||
BeforeQuotaRename,
|
||||
BeforeTransactionEpochVerify,
|
||||
BeforeObjectPublication,
|
||||
AfterObjectPublication,
|
||||
AfterRename,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
struct MultipartCommitBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
@@ -87,22 +85,27 @@ struct MultipartCommitBarrierState {
|
||||
release: tokio::sync::Semaphore,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub struct MultipartCommitBarrier {
|
||||
#[cfg(test)]
|
||||
pub(crate) struct MultipartCommitBarrier {
|
||||
state: Arc<MultipartCommitBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
static MULTIPART_COMMIT_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<MultipartCommitBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
impl MultipartCommitBarrier {
|
||||
pub fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self {
|
||||
pub(crate) fn install(bucket: &str, object: &str, pause: MultipartCommitPause) -> Self {
|
||||
Self::install_for_arrivals(bucket, object, pause, 1)
|
||||
}
|
||||
|
||||
pub fn install_for_arrivals(bucket: &str, object: &str, pause: MultipartCommitPause, expected_arrivals: usize) -> Self {
|
||||
pub(crate) fn install_for_arrivals(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
pause: MultipartCommitPause,
|
||||
expected_arrivals: usize,
|
||||
) -> Self {
|
||||
assert!(expected_arrivals > 0, "multipart commit barrier must wait for at least one arrival");
|
||||
let state = Arc::new(MultipartCommitBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
@@ -123,7 +126,7 @@ impl MultipartCommitBarrier {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub async fn wait_until_paused(&self) {
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let arrived = self.state.arrived.notified();
|
||||
@@ -137,12 +140,12 @@ impl MultipartCommitBarrier {
|
||||
.expect("multipart completion should reach the deterministic commit barrier");
|
||||
}
|
||||
|
||||
pub fn release(&self) {
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.add_permits(self.state.expected_arrivals);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
impl Drop for MultipartCommitBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
@@ -156,7 +159,7 @@ impl Drop for MultipartCommitBarrier {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[cfg(test)]
|
||||
async fn pause_multipart_commit(bucket: &str, object: &str, pause: MultipartCommitPause) {
|
||||
let barrier = {
|
||||
let mut slot = MULTIPART_COMMIT_BARRIER
|
||||
@@ -1889,24 +1892,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
|
||||
fi.parts = Vec::with_capacity(uploaded_parts.len());
|
||||
|
||||
let quota_context = reservation::begin(
|
||||
&self.ctx,
|
||||
bucket,
|
||||
object,
|
||||
opts.quota_admission,
|
||||
opts.data_movement,
|
||||
self.pool_index,
|
||||
self.set_index,
|
||||
)
|
||||
.await?;
|
||||
let quota_mutation_fence = quota_context.is_enforced() || opts.quota_admission.is_some();
|
||||
let preserve_replication_ciphertext = opts.replication_request
|
||||
&& contains_key_str(&fi.metadata, rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT);
|
||||
if quota_context.is_enforced() && preserve_replication_ciphertext {
|
||||
return Err(Error::PartMissingOrCorrupt);
|
||||
}
|
||||
let transformed_object = fi.is_compressed() || should_persist_encryption_original_size(&fi.metadata);
|
||||
|
||||
let mut object_size: usize = 0;
|
||||
let mut object_actual_size: i64 = 0;
|
||||
|
||||
@@ -2033,23 +2018,15 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
checksum_combined.extend_from_slice(cs.raw.as_slice());
|
||||
}
|
||||
|
||||
object_size = object_size.checked_add(ext_part.size).ok_or(Error::PartMissingOrCorrupt)?;
|
||||
if ext_part.actual_size < 0 && (!opts.replication_request || quota_context.is_enforced()) {
|
||||
object_size += ext_part.size;
|
||||
if opts.quota_admission.is_some() && ext_part.actual_size < 0 {
|
||||
return Err(Error::PartMissingOrCorrupt);
|
||||
}
|
||||
let normalized_actual_size = if ext_part.actual_size >= 0 && !transformed_object {
|
||||
ext_part
|
||||
.actual_size
|
||||
.max(i64::try_from(ext_part.size).map_err(|_| Error::PartMissingOrCorrupt)?)
|
||||
} else {
|
||||
ext_part.actual_size
|
||||
};
|
||||
object_actual_size = object_actual_size
|
||||
.checked_add(normalized_actual_size)
|
||||
.checked_add(ext_part.actual_size)
|
||||
.ok_or(Error::PartMissingOrCorrupt)?;
|
||||
let mut completed_part = completed_multipart_object_part(p.part_num, ext_part);
|
||||
completed_part.actual_size = normalized_actual_size;
|
||||
fi.parts.push(completed_part);
|
||||
|
||||
fi.parts.push(completed_multipart_object_part(p.part_num, ext_part));
|
||||
}
|
||||
|
||||
if let Some(wtcs) = opts.want_checksum.as_ref() {
|
||||
@@ -2076,34 +2053,15 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
let declared_replication_actual_size = opts
|
||||
.replication_request
|
||||
.then(|| get_str(&opts.user_defined, SUFFIX_ACTUAL_OBJECT_SIZE_CAP))
|
||||
.flatten();
|
||||
let replication_actual_size = if opts.replication_request && quota_context.is_enforced() {
|
||||
let observed_size = u64::try_from(object_actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
let declared_cap = declared_replication_actual_size
|
||||
.as_deref()
|
||||
.map(|value| value.parse::<u64>().map_err(|_| Error::PartMissingOrCorrupt))
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
let declared_encryption_size = rustfs_utils::http::get_object_encryption_original_size(&fi.metadata)
|
||||
.map_err(Error::other)?
|
||||
.map(u64::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| Error::PartMissingOrCorrupt)?
|
||||
.unwrap_or(0);
|
||||
Some(observed_size.max(declared_cap).max(declared_encryption_size))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let quota_new_size = match replication_actual_size {
|
||||
Some(size) => size.max(u64::try_from(object_size).map_err(|_| Error::PartMissingOrCorrupt)?),
|
||||
None if quota_context.is_enforced() => u64::try_from(object_actual_size)
|
||||
.map_err(|_| Error::PartMissingOrCorrupt)?
|
||||
.max(u64::try_from(object_size).map_err(|_| Error::PartMissingOrCorrupt)?),
|
||||
None => 0,
|
||||
};
|
||||
if let Some(admission) = opts.quota_admission {
|
||||
let quota_operation_size = u64::try_from(object_actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
if quota_operation_size > admission.remaining() {
|
||||
return Err(Error::QuotaExceeded {
|
||||
current: admission.current_usage(),
|
||||
limit: admission.quota_limit(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(rc_crc) = get_header_map(&opts.user_defined, SUFFIX_REPLICATION_SSEC_CRC) {
|
||||
if let Ok(rc_crc_bytes) = base64_simd::STANDARD.decode_to_vec(&rc_crc) {
|
||||
fi.checksum = Some(Bytes::from(rc_crc_bytes));
|
||||
@@ -2176,13 +2134,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
.insert("x-rustfs-encryption-original-size".to_string(), actual_size.to_string());
|
||||
}
|
||||
} else if opts.replication_request {
|
||||
if let Some(actual_size) = replication_actual_size {
|
||||
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.to_string());
|
||||
if persist_encryption_original_size {
|
||||
fi.metadata
|
||||
.insert("x-rustfs-encryption-original-size".to_string(), actual_size.to_string());
|
||||
}
|
||||
} else if let Some(actual_size) = declared_replication_actual_size {
|
||||
if let Some(actual_size) = get_str(&opts.user_defined, SUFFIX_ACTUAL_OBJECT_SIZE_CAP) {
|
||||
insert_str(&mut fi.metadata, SUFFIX_ACTUAL_SIZE, actual_size.clone());
|
||||
if persist_encryption_original_size {
|
||||
fi.metadata
|
||||
@@ -2372,41 +2324,8 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let quota_old_size = if quota_context.is_enforced() {
|
||||
if opts.data_movement {
|
||||
quota_new_size
|
||||
} else {
|
||||
reservation::replaced_logical_size(&self, bucket, object, opts).await?
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let quota_reservation = quota_context.reserve(quota_old_size, quota_new_size).await?;
|
||||
let (commit_disks, quota_fence_tokens) = if quota_mutation_fence {
|
||||
match Self::prepare_quota_mutation_fences(&shuffle_disks, bucket, object, write_quorum).await {
|
||||
Ok((disks, tokens)) => {
|
||||
for (metadata, token) in parts_metadatas.iter_mut().zip(tokens.iter().copied()) {
|
||||
if let Some(token) = token {
|
||||
insert_str(
|
||||
&mut metadata.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
token.as_uuid().to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
(disks, tokens)
|
||||
}
|
||||
Err(err) => {
|
||||
quota_reservation.abort().await;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(shuffle_disks.clone(), vec![None; shuffle_disks.len()])
|
||||
};
|
||||
let transaction_epoch =
|
||||
transaction_epoch_fence.map(|_| assign_object_transaction_epoch(&commit_disks, &mut parts_metadatas));
|
||||
transaction_epoch_fence.map(|_| assign_object_transaction_epoch(&shuffle_disks, &mut parts_metadatas));
|
||||
|
||||
let commit_set = self.clone();
|
||||
let commit_bucket = bucket.to_owned();
|
||||
@@ -2414,121 +2333,41 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
let commit_upload_id = upload_id.to_owned();
|
||||
let commit_upload_id_path = upload_id_path.clone();
|
||||
let commit_version_suspended = opts.version_suspended;
|
||||
let commit_versioned = opts.versioned;
|
||||
let commit_version_id = opts.version_id.clone();
|
||||
let commit_namespace_lock_fence = opts.namespace_lock_fence.clone();
|
||||
let commit_bucket_lifecycle_lock_fence = opts.bucket_lifecycle_lock_fence.clone();
|
||||
let commit_is_versioned = opts.versioned || opts.version_suspended;
|
||||
let commit_capacity_scope_token = opts.capacity_scope_token;
|
||||
let commit_object_lock_guard = object_lock_guard.take();
|
||||
let detach_commit_owner = commit_object_lock_guard.is_some() || upload_guard.is_some() || quota_mutation_fence;
|
||||
let detach_commit_owner = commit_object_lock_guard.is_some() || upload_guard.is_some();
|
||||
let commit = async move {
|
||||
let _object_lock_guard = commit_object_lock_guard;
|
||||
let _upload_guard = upload_guard;
|
||||
let mut quota_reservation = quota_reservation;
|
||||
let complete_tail_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
|
||||
|
||||
let pre_rename_result: Result<()> = async {
|
||||
// Crash-consistency injection: hard power loss after the upload is fully
|
||||
// staged and locked but before the authoritative rename_data commit. No
|
||||
// disk has moved the staged data, so a crash here must leave any prior
|
||||
// committed version byte-for-byte intact (rustfs/backlog#864) and the
|
||||
// upload fully retryable. Compiles to a no-op outside `#[cfg(test)]`.
|
||||
if crash_inject::should_crash_at(CrashPoint::MultipartBeforeCommitRename, &commit_object) {
|
||||
return Err(StorageError::Unexpected);
|
||||
}
|
||||
quota_reservation.mark_commit_started().await?;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::BeforeQuotaRename).await;
|
||||
if quota_reservation.is_lock_lost()
|
||||
|| !quota_reservation.capability_proof_matches()
|
||||
|| _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| _upload_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| commit_namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| commit_bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_reservation",
|
||||
bucket: commit_bucket.clone(),
|
||||
object: commit_object.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
let restore_opts = ObjectOptions {
|
||||
version_id: commit_version_id.clone(),
|
||||
versioned: commit_versioned,
|
||||
version_suspended: commit_version_suspended,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
commit_set
|
||||
.require_current_restore_operation_id(
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
&restore_opts,
|
||||
expected_restore_operation_id,
|
||||
"complete_multipart_upload_quota_reservation",
|
||||
)
|
||||
.await?;
|
||||
if let Some(proof) = transaction_fencing_proof.as_ref()
|
||||
&& !object_transaction_fencing_fleet_proof_matches(proof)
|
||||
{
|
||||
return Err(Error::other(
|
||||
"object transaction fencing fleet capability changed during complete_multipart_upload",
|
||||
));
|
||||
}
|
||||
if let Some(expected) = transaction_epoch_fence {
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::BeforeTransactionEpochVerify)
|
||||
.await;
|
||||
verify_object_transaction_epoch_fence(&commit_set, &commit_bucket, &commit_object, expected).await?;
|
||||
}
|
||||
if quota_reservation.is_lock_lost()
|
||||
|| !quota_reservation.capability_proof_matches()
|
||||
|| _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| _upload_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| commit_namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| commit_bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_reservation",
|
||||
bucket: commit_bucket.clone(),
|
||||
object: commit_object.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = pre_rename_result {
|
||||
SetDisks::abort_quota_reservation_after_fence(
|
||||
quota_reservation,
|
||||
&commit_disks,
|
||||
"a_fence_tokens,
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
write_quorum,
|
||||
quota_mutation_fence,
|
||||
)
|
||||
.await;
|
||||
return Err(err);
|
||||
// Crash-consistency injection: hard power loss after the upload is fully
|
||||
// staged and locked but before the authoritative rename_data commit. No
|
||||
// disk has moved the staged data, so a crash here must leave any prior
|
||||
// committed version byte-for-byte intact (rustfs/backlog#864) and the
|
||||
// upload fully retryable. Compiles to a no-op outside `#[cfg(test)]`.
|
||||
if crash_inject::should_crash_at(CrashPoint::MultipartBeforeCommitRename, &commit_object) {
|
||||
return Err(StorageError::Unexpected);
|
||||
}
|
||||
|
||||
// The trailing `_` drops the rename_data old-size backfill
|
||||
// (rustfs/backlog#1009): CompleteMultipartUpload keeps its pre-commit
|
||||
// `get_object_info` lookup, so the backfill has no consumer here yet.
|
||||
let rename_result = SetDisks::rename_data(
|
||||
&commit_disks,
|
||||
if let Some(proof) = transaction_fencing_proof.as_ref()
|
||||
&& !object_transaction_fencing_fleet_proof_matches(proof)
|
||||
{
|
||||
return Err(Error::other(
|
||||
"object transaction fencing fleet capability changed during complete_multipart_upload",
|
||||
));
|
||||
}
|
||||
if let Some(expected) = transaction_epoch_fence {
|
||||
#[cfg(test)]
|
||||
pause_multipart_commit(&commit_bucket, &commit_object, MultipartCommitPause::BeforeTransactionEpochVerify).await;
|
||||
verify_object_transaction_epoch_fence(&commit_set, &commit_bucket, &commit_object, expected).await?;
|
||||
}
|
||||
let (online_disks, convergence, op_old_dir, cleanup_disks, _) = SetDisks::rename_data(
|
||||
&shuffle_disks,
|
||||
RUSTFS_META_MULTIPART_BUCKET,
|
||||
&commit_upload_id_path,
|
||||
&parts_metadatas,
|
||||
@@ -2536,24 +2375,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
&commit_object,
|
||||
write_quorum,
|
||||
)
|
||||
.await;
|
||||
if quota_mutation_fence {
|
||||
let _ = SetDisks::release_quota_mutation_fences(
|
||||
&commit_disks,
|
||||
"a_fence_tokens,
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
write_quorum,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if rename_result.is_ok() {
|
||||
quota_reservation.commit().await;
|
||||
}
|
||||
let (online_disks, convergence, op_old_dir, cleanup_disks, _) = match rename_result {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
.await?;
|
||||
|
||||
// Detach admission before any post-commit await: client cancellation
|
||||
// must not couple durable convergence repair to cleanup work.
|
||||
@@ -3340,7 +3162,7 @@ mod tests {
|
||||
let (upload_id, parts) =
|
||||
stage_upload_with_create_opts(&set_disks, bucket, object, &payload, &ObjectOptions::default()).await;
|
||||
let mut denied_opts = ObjectOptions::default();
|
||||
assert!(denied_opts.set_quota_admission(100, 4180));
|
||||
assert!(denied_opts.set_quota_admission(100, 4195));
|
||||
|
||||
let err = set_disks
|
||||
.clone()
|
||||
@@ -3351,7 +3173,7 @@ mod tests {
|
||||
err,
|
||||
StorageError::QuotaExceeded {
|
||||
current: 100,
|
||||
limit: 4180
|
||||
limit: 4195
|
||||
}
|
||||
));
|
||||
|
||||
@@ -3369,7 +3191,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut allowed_opts = ObjectOptions::default();
|
||||
assert!(allowed_opts.set_quota_admission(100, 4181));
|
||||
assert!(allowed_opts.set_quota_admission(100, 4196));
|
||||
let completed = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload_id, parts, &allowed_opts)
|
||||
@@ -3410,120 +3232,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replication_quota_uses_server_observed_part_size_as_lower_bound() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "multipart-replication-quota-bucket";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disk_stores, bucket).await;
|
||||
|
||||
let mut create_opts = ObjectOptions::default();
|
||||
insert_str(&mut create_opts.user_defined, SUFFIX_COMPRESSION, "S2".to_string());
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(bucket, object, &create_opts)
|
||||
.await
|
||||
.expect("replication multipart upload should be created");
|
||||
let part = put_test_part(&set_disks, bucket, object, &upload.upload_id, 1, &[0x72; 4096], 1).await;
|
||||
let mut complete_opts = ObjectOptions {
|
||||
replication_request: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(complete_opts.set_quota_admission(0, 4095));
|
||||
|
||||
let err = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part.clone()], &complete_opts)
|
||||
.await
|
||||
.expect_err("a forged tiny replication logical size must not reduce quota admission");
|
||||
assert!(matches!(err, StorageError::QuotaExceeded { current: 0, limit: 4095 }));
|
||||
assert!(
|
||||
set_disks
|
||||
.check_upload_id_exists(bucket, object, &upload.upload_id, false)
|
||||
.await
|
||||
.is_ok(),
|
||||
"quota rejection must leave replicated multipart parts retryable"
|
||||
);
|
||||
|
||||
assert!(complete_opts.set_quota_admission(0, 4096));
|
||||
let completed = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
|
||||
.await
|
||||
.expect("the physical safety boundary should admit the transformed replica");
|
||||
assert_eq!(completed.get_actual_size().expect("replica logical size should parse"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_multipart_quota_uses_server_observed_part_size_as_lower_bound() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "multipart-direct-quota-bucket";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disk_stores, bucket).await;
|
||||
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("multipart upload should be created");
|
||||
let part = put_test_part(&set_disks, bucket, object, &upload.upload_id, 1, &[0x73; 4096], 1).await;
|
||||
let mut complete_opts = ObjectOptions::default();
|
||||
assert!(complete_opts.set_quota_admission(0, 4095));
|
||||
|
||||
let err = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
|
||||
.await
|
||||
.expect_err("a forged tiny direct logical size must not reduce quota admission");
|
||||
assert!(matches!(err, StorageError::QuotaExceeded { current: 0, limit: 4095 }));
|
||||
assert!(
|
||||
set_disks
|
||||
.check_upload_id_exists(bucket, object, &upload.upload_id, false)
|
||||
.await
|
||||
.is_ok(),
|
||||
"quota rejection must leave direct multipart parts retryable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_rejects_ciphertext_replication_without_a_server_observed_logical_size() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "multipart-compressed-ciphertext-quota-bucket";
|
||||
let object = "object";
|
||||
make_bucket_on_all(&disk_stores, bucket).await;
|
||||
|
||||
let mut create_opts = ObjectOptions::default();
|
||||
insert_str(&mut create_opts.user_defined, SUFFIX_COMPRESSION, "S2".to_string());
|
||||
insert_str(
|
||||
&mut create_opts.user_defined,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT,
|
||||
"true".to_string(),
|
||||
);
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(bucket, object, &create_opts)
|
||||
.await
|
||||
.expect("ciphertext multipart upload should be created");
|
||||
let payload = vec![0x74; 4096];
|
||||
let part = put_test_part(&set_disks, bucket, object, &upload.upload_id, 1, &payload, 1).await;
|
||||
let mut complete_opts = ObjectOptions {
|
||||
replication_request: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(complete_opts.set_quota_admission(0, u64::MAX));
|
||||
|
||||
let err = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload.upload_id, vec![part], &complete_opts)
|
||||
.await
|
||||
.expect_err("ciphertext replication has no server-observed logical quota size");
|
||||
assert!(matches!(err, StorageError::PartMissingOrCorrupt));
|
||||
assert!(
|
||||
set_disks
|
||||
.check_upload_id_exists(bucket, object, &upload.upload_id, false)
|
||||
.await
|
||||
.is_ok(),
|
||||
"rejection must leave ciphertext multipart parts retryable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_multipart_quota_rejects_invalid_logical_sizes() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
|
||||
@@ -40,7 +40,6 @@ use crate::bucket::lifecycle::{
|
||||
save_transition_transaction_record,
|
||||
},
|
||||
};
|
||||
use crate::bucket::quota::reservation;
|
||||
use crate::bucket::replication::{
|
||||
DeleteReplicationConfigSnapshot, VersionPurgeStatusType, replication_state_to_filemeta, version_purge_status_to_filemeta,
|
||||
};
|
||||
@@ -58,40 +57,9 @@ use http::HeaderValue;
|
||||
use rustfs_utils::path::decode_dir_object;
|
||||
use std::future::Future;
|
||||
use std::sync::OnceLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const OLD_DATA_CLEANUP_RECEIPT_FILE: &str = ".rustfs-old-data-cleanup-receipt.json";
|
||||
|
||||
struct PutObjectCommitCancellation {
|
||||
token: CancellationToken,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl PutObjectCommitCancellation {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
token: CancellationToken::new(),
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn child_token(&self) -> CancellationToken {
|
||||
self.token.clone()
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PutObjectCommitCancellation {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
self.token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn duration_millis_f64(duration: std::time::Duration) -> f64 {
|
||||
duration.as_secs_f64() * 1000.0
|
||||
@@ -1125,6 +1093,8 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
||||
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
||||
|
||||
let (producer_terminal_tx, producer_terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let rd = LegacyDuplexProducerReader::new(rd, producer_terminal_rx);
|
||||
let (mut reader, offset, length) =
|
||||
get_object_reader_with_context(&self.ctx, Box::new(rd), range, &object_info, opts, &h).await?;
|
||||
// Carry the hook probe result so the app layer skips its now-redundant
|
||||
@@ -1146,7 +1116,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
// `get_object_with_fileinfo` also waits on `writer`, so an outer timeout
|
||||
// would incorrectly treat downstream backpressure as disk-read latency.
|
||||
// Disk read timeouts must be enforced at the actual disk I/O operations.
|
||||
if let Err(e) = Self::get_object_with_fileinfo(
|
||||
let producer_result = Self::get_object_with_fileinfo(
|
||||
&bucket,
|
||||
&object,
|
||||
erasure_cache,
|
||||
@@ -1164,9 +1134,9 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
object_class.as_str(),
|
||||
size_bucket,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let reason = classify_storage_error(&e);
|
||||
.await;
|
||||
if let Err(e) = &producer_result {
|
||||
let reason = classify_storage_error(e);
|
||||
if reason == GetObjectFailureReason::DownstreamClosed {
|
||||
debug!(
|
||||
event = EVENT_SET_DISK_WRITE,
|
||||
@@ -1205,6 +1175,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
);
|
||||
}
|
||||
};
|
||||
let _ = producer_terminal_tx.send(producer_result.map(|_| ()));
|
||||
});
|
||||
|
||||
Ok(reader)
|
||||
@@ -1373,26 +1344,6 @@ impl SetDisks {
|
||||
object: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, Option<OldCurrentSize>)> {
|
||||
self.put_object_with_old_current_size_boxed(bucket, object, data, opts).await
|
||||
}
|
||||
|
||||
fn put_object_with_old_current_size_boxed<'a>(
|
||||
&'a self,
|
||||
bucket: &'a str,
|
||||
object: &'a str,
|
||||
data: &'a mut PutObjReader,
|
||||
opts: &'a ObjectOptions,
|
||||
) -> impl Future<Output = Result<(ObjectInfo, Option<OldCurrentSize>)>> + Send + 'a {
|
||||
Box::pin(self.put_object_with_old_current_size_inner(bucket, object, data, opts))
|
||||
}
|
||||
|
||||
async fn put_object_with_old_current_size_inner(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
data: &mut PutObjReader,
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<(ObjectInfo, Option<OldCurrentSize>)> {
|
||||
crate::hp_guard!("SetDisks::put_object");
|
||||
let storage_class_config = self.storage_class_config_snapshot();
|
||||
@@ -1981,125 +1932,8 @@ impl SetDisks {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let quota_context = reservation::begin(
|
||||
&self.ctx,
|
||||
bucket,
|
||||
object,
|
||||
opts.quota_admission,
|
||||
opts.data_movement,
|
||||
self.pool_index,
|
||||
self.set_index,
|
||||
)
|
||||
.await?;
|
||||
let quota_mutation_fence = quota_context.is_enforced() || opts.quota_admission.is_some();
|
||||
let mut replication_quota_size = None;
|
||||
|
||||
if opts.replication_request {
|
||||
if quota_context.is_enforced() && opts.preserve_ciphertext {
|
||||
return Err(Error::PartMissingOrCorrupt);
|
||||
}
|
||||
if quota_context.is_enforced() {
|
||||
let persisted_metadata = &parts_metadatas[response_metadata_slot].metadata;
|
||||
let observed_size = u64::try_from(actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
let physical_size = u64::try_from(w_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
let transformed = contains_key_str(persisted_metadata, SUFFIX_COMPRESSION)
|
||||
|| should_persist_encryption_original_size(persisted_metadata);
|
||||
let declared_size = get_str(persisted_metadata, SUFFIX_ACTUAL_SIZE)
|
||||
.map(|value| value.parse::<u64>().map_err(|_| Error::PartMissingOrCorrupt))
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
let declared_encryption_size = rustfs_utils::http::get_object_encryption_original_size(persisted_metadata)
|
||||
.map_err(Error::other)?
|
||||
.map(u64::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| Error::PartMissingOrCorrupt)?
|
||||
.unwrap_or(0);
|
||||
let logical_size = observed_size.max(declared_size).max(declared_encryption_size);
|
||||
let persisted_size = if transformed {
|
||||
logical_size
|
||||
} else {
|
||||
logical_size.max(physical_size)
|
||||
};
|
||||
replication_quota_size = Some(logical_size.max(physical_size));
|
||||
actual_size = i64::try_from(persisted_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
for metadata in &mut parts_metadatas {
|
||||
insert_str(&mut metadata.metadata, SUFFIX_ACTUAL_SIZE, persisted_size.to_string());
|
||||
if should_persist_encryption_original_size(&metadata.metadata) {
|
||||
metadata
|
||||
.metadata
|
||||
.insert("x-rustfs-encryption-original-size".to_string(), persisted_size.to_string());
|
||||
}
|
||||
if let Some(part) = metadata.parts.first_mut() {
|
||||
part.actual_size = actual_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if actual_size >= 0 {
|
||||
let observed_size = u64::try_from(actual_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
let persisted_metadata = &parts_metadatas[response_metadata_slot].metadata;
|
||||
let transformed = contains_key_str(persisted_metadata, SUFFIX_COMPRESSION)
|
||||
|| should_persist_encryption_original_size(persisted_metadata);
|
||||
let server_observed_size = if transformed {
|
||||
observed_size
|
||||
} else {
|
||||
observed_size.max(u64::try_from(w_size).map_err(|_| Error::PartMissingOrCorrupt)?)
|
||||
};
|
||||
actual_size = i64::try_from(server_observed_size).map_err(|_| Error::PartMissingOrCorrupt)?;
|
||||
for metadata in &mut parts_metadatas {
|
||||
insert_str(&mut metadata.metadata, SUFFIX_ACTUAL_SIZE, server_observed_size.to_string());
|
||||
if should_persist_encryption_original_size(&metadata.metadata) {
|
||||
metadata
|
||||
.metadata
|
||||
.insert("x-rustfs-encryption-original-size".to_string(), server_observed_size.to_string());
|
||||
}
|
||||
if let Some(part) = metadata.parts.first_mut() {
|
||||
part.actual_size = actual_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (quota_old_size, quota_new_size) = if quota_context.is_enforced() {
|
||||
let new_size = match replication_quota_size {
|
||||
Some(size) => size,
|
||||
None => u64::try_from(actual_size)
|
||||
.map_err(|_| Error::PartMissingOrCorrupt)?
|
||||
.max(u64::try_from(w_size).map_err(|_| Error::PartMissingOrCorrupt)?),
|
||||
};
|
||||
let old_size = if opts.data_movement {
|
||||
new_size
|
||||
} else {
|
||||
reservation::replaced_logical_size(self, bucket, object, opts).await?
|
||||
};
|
||||
(old_size, new_size)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let quota_reservation = quota_context.reserve(quota_old_size, quota_new_size).await?;
|
||||
let (commit_disks, quota_fence_tokens) = if quota_mutation_fence {
|
||||
match Self::prepare_quota_mutation_fences(&shuffle_disks, bucket, object, write_quorum).await {
|
||||
Ok((disks, tokens)) => {
|
||||
for (metadata, token) in parts_metadatas.iter_mut().zip(tokens.iter().copied()) {
|
||||
if let Some(token) = token {
|
||||
insert_str(
|
||||
&mut metadata.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
token.as_uuid().to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
(disks, tokens)
|
||||
}
|
||||
Err(err) => {
|
||||
quota_reservation.abort().await;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(shuffle_disks.clone(), vec![None; shuffle_disks.len()])
|
||||
};
|
||||
let transaction_epoch =
|
||||
transaction_epoch_fence.map(|_| assign_object_transaction_epoch(&commit_disks, &mut parts_metadatas));
|
||||
transaction_epoch_fence.map(|_| assign_object_transaction_epoch(&shuffle_disks, &mut parts_metadatas));
|
||||
|
||||
let commit_set = self.clone();
|
||||
let commit_bucket = bucket.to_owned();
|
||||
@@ -2107,65 +1941,18 @@ impl SetDisks {
|
||||
let commit_tmp_dir = tmp_dir.clone();
|
||||
let commit_object_lock_guard = object_lock_guard.take();
|
||||
let commit_bucket_lifecycle_guard = bucket_lifecycle_guard.take();
|
||||
let detach_commit_owner =
|
||||
commit_object_lock_guard.is_some() || commit_bucket_lifecycle_guard.is_some() || quota_mutation_fence;
|
||||
let detach_commit_owner = commit_object_lock_guard.is_some() || commit_bucket_lifecycle_guard.is_some();
|
||||
let commit_write_path_label = write_path.metric_label();
|
||||
let commit_is_versioned = opts.versioned || opts.version_suspended;
|
||||
let commit_versioned = opts.versioned;
|
||||
let commit_version_suspended = opts.version_suspended;
|
||||
let commit_version_id = opts.version_id.clone();
|
||||
let commit_namespace_lock_fence = opts.namespace_lock_fence.clone();
|
||||
let commit_bucket_lifecycle_lock_fence = opts.bucket_lifecycle_lock_fence.clone();
|
||||
let commit_capacity_scope_token = opts.capacity_scope_token;
|
||||
let commit_replication_state = replication_state_to_filemeta(&opts.put_replication_state());
|
||||
tmp_cleanup_owned = true;
|
||||
|
||||
let commit = move |cancellation: Option<CancellationToken>| async move {
|
||||
let commit = async move {
|
||||
let _object_lock_guard = commit_object_lock_guard;
|
||||
let _bucket_lifecycle_guard = commit_bucket_lifecycle_guard;
|
||||
let mut quota_reservation = quota_reservation;
|
||||
let rename_stage_start = Instant::now();
|
||||
let pre_rename = async {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_put_object_commit(&commit_bucket, &commit_object, PutObjectCommitPause::AfterQuotaReservation).await;
|
||||
quota_reservation.mark_commit_started().await?;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pause_put_object_commit(&commit_bucket, &commit_object, PutObjectCommitPause::BeforeQuotaRename).await;
|
||||
if quota_reservation.is_lock_lost()
|
||||
|| !quota_reservation.capability_proof_matches()
|
||||
|| _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| commit_namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| commit_bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| _bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_reservation",
|
||||
bucket: commit_bucket.clone(),
|
||||
object: commit_object.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
let restore_opts = ObjectOptions {
|
||||
version_id: commit_version_id.clone(),
|
||||
versioned: commit_versioned,
|
||||
version_suspended: commit_version_suspended,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
commit_set
|
||||
.require_current_restore_operation_id(
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
&restore_opts,
|
||||
expected_restore_operation_id,
|
||||
"put_object_quota_reservation",
|
||||
)
|
||||
.await?;
|
||||
let pre_rename_result: Result<()> = async {
|
||||
if let Some(proof) = transaction_fencing_proof.as_ref()
|
||||
&& !object_transaction_fencing_fleet_proof_matches(proof)
|
||||
{
|
||||
@@ -2181,47 +1968,10 @@ impl SetDisks {
|
||||
.await;
|
||||
verify_object_transaction_epoch_fence(&commit_set, &commit_bucket, &commit_object, expected).await?;
|
||||
}
|
||||
if quota_reservation.is_lock_lost()
|
||||
|| !quota_reservation.capability_proof_matches()
|
||||
|| _object_lock_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
|| commit_namespace_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| commit_bucket_lifecycle_lock_fence
|
||||
.as_ref()
|
||||
.is_some_and(NamespaceLockFence::is_lock_lost)
|
||||
|| _bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|
||||
{
|
||||
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_reservation",
|
||||
bucket: commit_bucket.clone(),
|
||||
object: commit_object.clone(),
|
||||
required: 1,
|
||||
achieved: 0,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let pre_rename_result = if let Some(cancellation) = cancellation {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => Err(StorageError::OperationCanceled),
|
||||
result = pre_rename => result,
|
||||
}
|
||||
} else {
|
||||
pre_rename.await
|
||||
};
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = pre_rename_result {
|
||||
SetDisks::abort_quota_reservation_after_fence(
|
||||
quota_reservation,
|
||||
&commit_disks,
|
||||
"a_fence_tokens,
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
write_quorum,
|
||||
quota_mutation_fence,
|
||||
)
|
||||
.await;
|
||||
if let Err(cleanup_err) = commit_set.delete_all(RUSTFS_META_TMP_BUCKET, &commit_tmp_dir).await {
|
||||
warn!(tmp_dir = %commit_tmp_dir, error = ?cleanup_err, "failed to cleanup put_object temporary data");
|
||||
} else if issue3031_diag_enabled() {
|
||||
@@ -2235,9 +1985,8 @@ impl SetDisks {
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let rename_result = SetDisks::rename_data(
|
||||
&commit_disks,
|
||||
&shuffle_disks,
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
commit_tmp_dir.as_str(),
|
||||
&parts_metadatas,
|
||||
@@ -2246,19 +1995,6 @@ impl SetDisks {
|
||||
write_quorum,
|
||||
)
|
||||
.await;
|
||||
if quota_mutation_fence {
|
||||
let _ = SetDisks::release_quota_mutation_fences(
|
||||
&commit_disks,
|
||||
"a_fence_tokens,
|
||||
&commit_bucket,
|
||||
&commit_object,
|
||||
write_quorum,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if rename_result.is_ok() {
|
||||
quota_reservation.commit().await;
|
||||
}
|
||||
let (online_disks, convergence, op_old_dir, cleanup_disks, old_current_size) = match rename_result {
|
||||
Ok(commit) => commit,
|
||||
Err(err) => {
|
||||
@@ -2469,15 +2205,11 @@ impl SetDisks {
|
||||
};
|
||||
|
||||
if detach_commit_owner {
|
||||
let mut cancellation = PutObjectCommitCancellation::new();
|
||||
let child_token = cancellation.child_token();
|
||||
let result = tokio::spawn(async move { Box::pin(commit(Some(child_token))).await })
|
||||
tokio::spawn(commit)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("put_object commit task failed: {err}")))?;
|
||||
cancellation.disarm();
|
||||
result
|
||||
.map_err(|err| Error::other(format!("put_object commit task failed: {err}")))?
|
||||
} else {
|
||||
Box::pin(commit(None)).await
|
||||
commit.await
|
||||
}
|
||||
}
|
||||
.await;
|
||||
@@ -2589,6 +2321,159 @@ impl<R: AsyncRead + Unpin> AsyncRead for TransitionUploadReader<R> {
|
||||
}
|
||||
}
|
||||
|
||||
struct LegacyDuplexProducerReader<R> {
|
||||
inner: R,
|
||||
terminal: Option<tokio::sync::oneshot::Receiver<Result<()>>>,
|
||||
inner_eof: bool,
|
||||
}
|
||||
|
||||
impl<R> LegacyDuplexProducerReader<R> {
|
||||
fn new(inner: R, terminal: tokio::sync::oneshot::Receiver<Result<()>>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
terminal: Some(terminal),
|
||||
inner_eof: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for LegacyDuplexProducerReader<R> {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
||||
if !self.inner_eof {
|
||||
let before = buf.filled().len();
|
||||
match Pin::new(&mut self.inner).poll_read(cx, buf) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
|
||||
Poll::Ready(Ok(())) if buf.filled().len() > before => return Poll::Ready(Ok(())),
|
||||
Poll::Ready(Ok(())) => {
|
||||
self.inner_eof = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(terminal) = self.terminal.as_mut() else {
|
||||
return Poll::Ready(Ok(()));
|
||||
};
|
||||
match Pin::new(terminal).poll(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Ok(Ok(()))) => {
|
||||
self.terminal = None;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Ok(Err(err))) => {
|
||||
self.terminal = None;
|
||||
Poll::Ready(Err(std::io::Error::other(err)))
|
||||
}
|
||||
Poll::Ready(Err(_)) => {
|
||||
self.terminal = None;
|
||||
Poll::Ready(Err(std::io::Error::other(StorageError::Unexpected)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod legacy_duplex_producer_reader_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
fn storage_error_source(error: &std::io::Error) -> &StorageError {
|
||||
error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("legacy duplex terminal error should retain StorageError source")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_allows_clean_completion() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer
|
||||
.write_all(b"complete")
|
||||
.await
|
||||
.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx.send(Ok(())).expect("terminal receiver should remain installed");
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut out = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect("clean producer completion should surface clean EOF");
|
||||
|
||||
assert_eq!(out, b"complete");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_surfaces_terminal_error_after_partial_data() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer.write_all(b"partial").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx
|
||||
.send(Err(StorageError::FileCorrupt))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("terminal producer error must not become clean EOF");
|
||||
|
||||
assert_eq!(out, b"partial");
|
||||
assert!(matches!(storage_error_source(&err), StorageError::FileCorrupt));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_surfaces_terminal_error_after_declared_length() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
writer.write_all(b"exact").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
terminal_tx
|
||||
.send(Err(StorageError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionReset,
|
||||
"remote body reset after final byte",
|
||||
))))
|
||||
.expect("terminal receiver should remain installed");
|
||||
|
||||
let reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut reader =
|
||||
HashReader::from_stream(reader, 5, 5, None, None, false).expect("hash reader should accept exact declared length");
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("producer terminal error after the declared length must still fail");
|
||||
|
||||
assert_eq!(out, b"exact");
|
||||
assert!(
|
||||
matches!(storage_error_source(&err), StorageError::Io(io_error) if io_error.kind() == std::io::ErrorKind::ConnectionReset)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_duplex_reader_fails_closed_when_terminal_channel_closes() {
|
||||
let (mut writer, reader) = tokio::io::duplex(64);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel::<Result<()>>();
|
||||
writer.write_all(b"body").await.expect("duplex write should fit in buffer");
|
||||
drop(writer);
|
||||
drop(terminal_tx);
|
||||
|
||||
let mut reader = LegacyDuplexProducerReader::new(reader, terminal_rx);
|
||||
let mut out = Vec::new();
|
||||
let err = reader
|
||||
.read_to_end(&mut out)
|
||||
.await
|
||||
.expect_err("producer disappearance must fail closed");
|
||||
|
||||
assert_eq!(out, b"body");
|
||||
assert!(matches!(storage_error_source(&err), StorageError::Unexpected));
|
||||
}
|
||||
}
|
||||
|
||||
struct TransitionUploadWriter<W> {
|
||||
inner: W,
|
||||
produced: u64,
|
||||
@@ -3496,8 +3381,6 @@ fn transaction_fencing_gate_requested_for(requested: bool, fleet_confirmed: bool
|
||||
pub enum PutObjectCommitPause {
|
||||
BeforeNamespace,
|
||||
AfterNamespace,
|
||||
AfterQuotaReservation,
|
||||
BeforeQuotaRename,
|
||||
BeforeMetadata,
|
||||
BeforeTransactionEpochVerify,
|
||||
}
|
||||
@@ -6568,139 +6451,6 @@ pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replication_quota_safety_tests {
|
||||
use super::hermetic_set_disks_support::hermetic_set_disks;
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_put_future_keeps_commit_state_off_the_caller_stack() {
|
||||
let (_temp_dirs, _disks, set_disks) = hermetic_set_disks(4).await;
|
||||
let mut reader = PutObjReader::from_vec(Vec::new());
|
||||
let opts = ObjectOptions::default();
|
||||
|
||||
let future = set_disks.put_object_with_old_current_size("bucket", "object", &mut reader, &opts);
|
||||
let future_size = std::mem::size_of_val(&future);
|
||||
|
||||
assert!(
|
||||
future_size <= 1024,
|
||||
"put_object_with_old_current_size future must stay stack-bounded, got {future_size} bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replication_put_quota_uses_physical_bytes_as_a_safety_floor() {
|
||||
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "replication-put-quota-safety";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
let mut user_defined = HashMap::new();
|
||||
insert_str(
|
||||
&mut user_defined,
|
||||
rustfs_utils::http::SUFFIX_COMPRESSION,
|
||||
"klauspost/compress/s2".to_string(),
|
||||
);
|
||||
insert_str(&mut user_defined, SUFFIX_ACTUAL_SIZE, "1".to_string());
|
||||
let payload = vec![0x61; 4096];
|
||||
|
||||
let mut denied_opts = ObjectOptions {
|
||||
replication_request: true,
|
||||
user_defined: user_defined.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(denied_opts.set_quota_admission(0, 4095));
|
||||
let mut denied_reader = PutObjReader::new(
|
||||
HashReader::from_stream(Cursor::new(payload.clone()), 4096, 1, None, None, false)
|
||||
.expect("construct forged replication reader"),
|
||||
);
|
||||
let err = set_disks
|
||||
.put_object(bucket, "object", &mut denied_reader, &denied_opts)
|
||||
.await
|
||||
.expect_err("server-observed bytes must prevent a tiny replication quota claim");
|
||||
assert!(matches!(err, StorageError::QuotaExceeded { current: 0, limit: 4095 }));
|
||||
|
||||
let mut allowed_opts = ObjectOptions {
|
||||
replication_request: true,
|
||||
user_defined,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(allowed_opts.set_quota_admission(0, 4096));
|
||||
let mut allowed_reader = PutObjReader::new(
|
||||
HashReader::from_stream(Cursor::new(payload), 4096, 1, None, None, false)
|
||||
.expect("construct exact-boundary replication reader"),
|
||||
);
|
||||
let stored = set_disks
|
||||
.put_object(bucket, "object", &mut allowed_reader, &allowed_opts)
|
||||
.await
|
||||
.expect("server-observed exact quota boundary should succeed");
|
||||
assert_eq!(stored.get_actual_size().expect("stored logical size should parse"), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_put_cannot_persist_a_tiny_logical_size() {
|
||||
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "direct-put-quota-safety";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
let payload = vec![0x62; 4096];
|
||||
let mut denied_opts = ObjectOptions::default();
|
||||
assert!(denied_opts.set_quota_admission(0, 4095));
|
||||
let mut denied_reader = PutObjReader::new(
|
||||
HashReader::from_stream(Cursor::new(payload.clone()), 4096, 1, None, None, false)
|
||||
.expect("construct forged direct reader"),
|
||||
);
|
||||
let err = set_disks
|
||||
.put_object(bucket, "object", &mut denied_reader, &denied_opts)
|
||||
.await
|
||||
.expect_err("server-observed bytes must prevent a tiny direct quota claim");
|
||||
assert!(matches!(err, StorageError::QuotaExceeded { current: 0, limit: 4095 }));
|
||||
|
||||
let mut allowed_opts = ObjectOptions::default();
|
||||
assert!(allowed_opts.set_quota_admission(0, 4096));
|
||||
let mut allowed_reader = PutObjReader::new(
|
||||
HashReader::from_stream(Cursor::new(payload), 4096, 1, None, None, false)
|
||||
.expect("construct exact-boundary direct reader"),
|
||||
);
|
||||
let stored = set_disks
|
||||
.put_object(bucket, "object", &mut allowed_reader, &allowed_opts)
|
||||
.await
|
||||
.expect("server-observed exact quota boundary should succeed");
|
||||
assert_eq!(stored.get_actual_size().expect("stored logical size should parse"), 4096);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_rejects_ciphertext_replication_without_a_server_observed_logical_size() {
|
||||
let (_temp_dirs, disks, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "ciphertext-replication-quota-safety";
|
||||
for disk in &disks {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
let mut user_defined = HashMap::new();
|
||||
user_defined.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string());
|
||||
insert_str(&mut user_defined, SUFFIX_ACTUAL_SIZE, "1".to_string());
|
||||
let mut opts = ObjectOptions {
|
||||
replication_request: true,
|
||||
preserve_ciphertext: true,
|
||||
user_defined,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(opts.set_quota_admission(0, u64::MAX));
|
||||
let payload = vec![0x63; 4096];
|
||||
let mut reader = PutObjReader::new(
|
||||
HashReader::from_stream(Cursor::new(payload), 4096, 4096, None, None, false)
|
||||
.expect("construct ciphertext replication reader"),
|
||||
);
|
||||
let err = set_disks
|
||||
.put_object(bucket, "object", &mut reader, &opts)
|
||||
.await
|
||||
.expect_err("ciphertext replication without a server-observed logical size must fail closed");
|
||||
assert!(matches!(err, StorageError::PartMissingOrCorrupt));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod inline_put_commit_path_tests {
|
||||
use super::hermetic_set_disks_support::hermetic_set_disks_isolated as hermetic_set_disks;
|
||||
|
||||
@@ -1174,32 +1174,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn quota_object_fence_ignores_an_unrelated_offline_pool() {
|
||||
let temp_dir = tempfile::tempdir().expect("create quota fence store dir");
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "quota-object-fence", &[4, 4])).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("quota-object-fence-{}", uuid::Uuid::new_v4());
|
||||
let object = "object.bin";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create quota fence bucket");
|
||||
store.pools[1].disk_set[0].disks.write().await.fill(None);
|
||||
|
||||
crate::bucket::quota::reservation::fence_namespace_mutations_for_test(&store, &bucket, object, Some((0, 0)))
|
||||
.await
|
||||
.expect("the selected pool fence should ignore an unrelated offline pool");
|
||||
let err = crate::bucket::quota::reservation::fence_namespace_mutations_for_test(&store, &bucket, object, None)
|
||||
.await
|
||||
.expect_err("legacy reservations must conservatively fence every pool");
|
||||
assert!(matches!(err, StorageError::ErasureWriteQuorum));
|
||||
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn tag_updates_skip_active_rebalance_source_pool() {
|
||||
|
||||
@@ -641,7 +641,7 @@ pub(crate) fn observe_scanner_namespace_mutations(bucket: &str, delta: u64) {
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| Some(current.saturating_add(delta)));
|
||||
}
|
||||
|
||||
pub(crate) async fn observe_list_objects_mutation(store: &ECStore, bucket: &str) -> u64 {
|
||||
pub(super) async fn observe_list_objects_mutation(store: &ECStore, bucket: &str) -> u64 {
|
||||
observe_list_objects_mutations(store, bucket, 1).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
|
||||
@@ -4741,10 +4741,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(body_cache_hook)]
|
||||
async fn select_snapshot_rejects_latest_versioned_delete_marker_during_prepare() {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
let (_first_dirs, first_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let (_second_dirs, second_set) = make_local_set_disks_with_ctx(4, 2, Arc::clone(&ctx)).await;
|
||||
let store = new_prepared_reader_test_store_with_ctx(&[Arc::clone(&first_set), Arc::clone(&second_set)], ctx).await;
|
||||
let (_first_dirs, first_set) = make_local_set_disks(4, 2).await;
|
||||
let (_second_dirs, second_set) = make_local_set_disks(4, 2).await;
|
||||
let store = new_prepared_reader_test_store(&[Arc::clone(&first_set), Arc::clone(&second_set)]).await;
|
||||
let bucket = "select-snapshot-latest-delete-marker";
|
||||
let object = "versioned-object.bin";
|
||||
let versioned_opts = ObjectOptions {
|
||||
|
||||
@@ -748,10 +748,9 @@ async fn handle_authenticated_request(
|
||||
}
|
||||
|
||||
for (key, value) in info.user_defined.iter() {
|
||||
if key != "content-type"
|
||||
&& let Some(key) = object::swift_response_user_metadata_key(key)
|
||||
{
|
||||
response = response.header(format!("x-object-meta-{key}"), value.as_str());
|
||||
if key != "content-type" {
|
||||
let header_name = format!("x-object-meta-{}", key);
|
||||
response = response.header(header_name, value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,10 +817,9 @@ async fn handle_authenticated_request(
|
||||
|
||||
// Add custom metadata headers (X-Object-Meta-*)
|
||||
for (key, value) in info.user_defined.iter() {
|
||||
if key != "content-type"
|
||||
&& let Some(key) = object::swift_response_user_metadata_key(key)
|
||||
{
|
||||
response = response.header(format!("x-object-meta-{key}"), value.as_str());
|
||||
if key != "content-type" {
|
||||
let header_name = format!("x-object-meta-{}", key);
|
||||
response = response.header(header_name, value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,10 +856,9 @@ async fn handle_authenticated_request(
|
||||
|
||||
// Add custom metadata headers (X-Object-Meta-*)
|
||||
for (key, value) in info.user_defined.iter() {
|
||||
if key != "content-type"
|
||||
&& let Some(key) = object::swift_response_user_metadata_key(key)
|
||||
{
|
||||
response = response.header(format!("x-object-meta-{key}"), value.as_str());
|
||||
if key != "content-type" {
|
||||
let header_name = format!("x-object-meta-{}", key);
|
||||
response = response.header(header_name, value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1171,10 +1168,9 @@ async fn handle_object_get(
|
||||
}
|
||||
|
||||
for (key, value) in info.user_defined.iter() {
|
||||
if key != "content-type"
|
||||
&& let Some(key) = object::swift_response_user_metadata_key(key)
|
||||
{
|
||||
response = response.header(format!("x-object-meta-{key}"), value.as_str());
|
||||
if key != "content-type" {
|
||||
let header_name = format!("x-object-meta-{}", key);
|
||||
response = response.header(header_name, value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1241,10 +1237,9 @@ async fn handle_object_get(
|
||||
if key == "x-delete-at" {
|
||||
// Add X-Delete-At header directly (not as X-Object-Meta-*)
|
||||
response = response.header("x-delete-at", value.as_str());
|
||||
} else if key != "content-type"
|
||||
&& let Some(key) = object::swift_response_user_metadata_key(key)
|
||||
{
|
||||
response = response.header(format!("x-object-meta-{key}"), value.as_str());
|
||||
} else if key != "content-type" {
|
||||
let header_name = format!("x-object-meta-{}", key);
|
||||
response = response.header(header_name, value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1298,10 +1293,9 @@ async fn handle_object_head(
|
||||
if key == "x-delete-at" {
|
||||
// Add X-Delete-At header directly (not as X-Object-Meta-*)
|
||||
response = response.header("x-delete-at", value.as_str());
|
||||
} else if key != "content-type"
|
||||
&& let Some(key) = object::swift_response_user_metadata_key(key)
|
||||
{
|
||||
response = response.header(format!("x-object-meta-{key}"), value.as_str());
|
||||
} else if key != "content-type" {
|
||||
let header_name = format!("x-object-meta-{}", key);
|
||||
response = response.header(header_name, value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,57 +67,10 @@ const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
|
||||
const LOG_SUBSYSTEM_SWIFT_OBJECT: &str = "swift_object";
|
||||
const EVENT_SWIFT_OBJECT_STORAGE_STATE: &str = "swift_object_storage_state";
|
||||
const SWIFT_DELETE_AT_METADATA: &str = "x-delete-at";
|
||||
const USER_METADATA_PREFIX: &str = "x-amz-meta-";
|
||||
|
||||
/// Maximum object size in bytes (5GB - Swift default)
|
||||
const MAX_OBJECT_SIZE: i64 = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
fn stored_swift_user_metadata_key(key: &str) -> String {
|
||||
if rustfs_utils::http::is_internal_key(key)
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-amz-")
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-rustfs-encryption-")
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-minio-encryption-")
|
||||
{
|
||||
format!("{USER_METADATA_PREFIX}{key}")
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn swift_response_user_metadata_key(key: &str) -> Option<&str> {
|
||||
if rustfs_utils::http::is_internal_key(key)
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-rustfs-encryption-")
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(key, "x-minio-encryption-")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(unescaped) = key.strip_prefix(USER_METADATA_PREFIX)
|
||||
&& (rustfs_utils::http::is_internal_key(unescaped)
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(unescaped, "x-amz-")
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(unescaped, "x-rustfs-encryption-")
|
||||
|| rustfs_utils::http::starts_with_ignore_ascii_case(unescaped, "x-minio-encryption-"))
|
||||
{
|
||||
return Some(unescaped);
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
fn swift_user_metadata(headers: &HeaderMap) -> Option<HashMap<String, String>> {
|
||||
let mut metadata = HashMap::new();
|
||||
let mut present = false;
|
||||
for (header_name, header_value) in headers.iter() {
|
||||
let header_name = header_name.as_str().to_lowercase();
|
||||
let Some(key) = header_name.strip_prefix("x-object-meta-") else {
|
||||
continue;
|
||||
};
|
||||
present = true;
|
||||
if let Ok(value) = header_value.to_str() {
|
||||
metadata.insert(stored_swift_user_metadata_key(key), value.to_string());
|
||||
}
|
||||
}
|
||||
present.then_some(metadata)
|
||||
}
|
||||
|
||||
/// Object key translator for Swift object names
|
||||
///
|
||||
/// Handles URL encoding/decoding and path normalization for Swift object keys.
|
||||
@@ -350,7 +303,15 @@ where
|
||||
let bucket = mapper.swift_to_s3_bucket(container, &project_id);
|
||||
|
||||
// 5. Extract Swift metadata from X-Object-Meta-* headers
|
||||
let mut user_metadata = swift_user_metadata(headers).unwrap_or_default();
|
||||
let mut user_metadata = HashMap::new();
|
||||
for (header_name, header_value) in headers.iter() {
|
||||
let header_str = header_name.as_str().to_lowercase();
|
||||
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-")
|
||||
&& let Ok(value_str) = header_value.to_str()
|
||||
{
|
||||
user_metadata.insert(meta_key.to_string(), value_str.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Extract Content-Type if provided
|
||||
if let Some(content_type) = headers.get("content-type")
|
||||
@@ -778,7 +739,15 @@ pub async fn update_object_metadata(
|
||||
}
|
||||
|
||||
// 8. Extract new metadata from X-Object-Meta-* headers
|
||||
let mut new_metadata = swift_user_metadata(headers).unwrap_or_default();
|
||||
let mut new_metadata = HashMap::new();
|
||||
for (header_name, header_value) in headers.iter() {
|
||||
let header_str = header_name.as_str().to_lowercase();
|
||||
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-")
|
||||
&& let Ok(value_str) = header_value.to_str()
|
||||
{
|
||||
new_metadata.insert(meta_key.to_string(), value_str.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Also update Content-Type if provided
|
||||
if let Some(content_type) = headers.get("content-type")
|
||||
@@ -920,8 +889,19 @@ pub async fn copy_object(
|
||||
let mut new_metadata = (*src_info.user_defined).clone();
|
||||
|
||||
// 11. If custom metadata headers provided, use those instead (Swift behavior)
|
||||
if let Some(custom_metadata) = swift_user_metadata(headers) {
|
||||
new_metadata = custom_metadata;
|
||||
let mut has_custom_meta = false;
|
||||
for (header_name, header_value) in headers.iter() {
|
||||
let header_str = header_name.as_str().to_lowercase();
|
||||
if let Some(meta_key) = header_str.strip_prefix("x-object-meta-") {
|
||||
if !has_custom_meta {
|
||||
// First custom meta header - clear source metadata
|
||||
new_metadata.clear();
|
||||
has_custom_meta = true;
|
||||
}
|
||||
if let Ok(value_str) = header_value.to_str() {
|
||||
new_metadata.insert(meta_key.to_string(), value_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 12. Also check for Content-Type override
|
||||
@@ -1143,36 +1123,6 @@ mod tests {
|
||||
assert!(ObjectKeyMapper::validate_object_name("unicode-文件.txt").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swift_user_metadata_cannot_materialize_internal_storage_keys() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-object-meta-x-rustfs-internal-actual-size", "1".parse().expect("valid metadata value"));
|
||||
headers.insert("x-object-meta-description", "safe".parse().expect("valid metadata value"));
|
||||
let metadata = swift_user_metadata(&headers).expect("custom metadata should be detected");
|
||||
|
||||
assert_eq!(metadata.get("x-amz-meta-x-rustfs-internal-actual-size").map(String::as_str), Some("1"));
|
||||
assert_eq!(metadata.get("description").map(String::as_str), Some("safe"));
|
||||
assert!(!metadata.contains_key("x-rustfs-internal-actual-size"));
|
||||
assert_eq!(
|
||||
stored_swift_user_metadata_key("x-minio-encryption-original-size"),
|
||||
"x-amz-meta-x-minio-encryption-original-size"
|
||||
);
|
||||
assert_eq!(stored_swift_user_metadata_key("description"), "description");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swift_user_metadata_response_mapping_is_reversible_and_filters_internal_keys() {
|
||||
assert_eq!(
|
||||
swift_response_user_metadata_key("x-amz-meta-x-rustfs-internal-actual-size"),
|
||||
Some("x-rustfs-internal-actual-size")
|
||||
);
|
||||
assert_eq!(swift_response_user_metadata_key("x-amz-meta-x-amz-checksum"), Some("x-amz-checksum"));
|
||||
assert_eq!(swift_response_user_metadata_key("x-amz-meta-description"), Some("x-amz-meta-description"));
|
||||
assert_eq!(swift_response_user_metadata_key("description"), Some("description"));
|
||||
assert_eq!(swift_response_user_metadata_key("x-rustfs-internal-actual-size"), None);
|
||||
assert_eq!(swift_response_user_metadata_key("x-minio-internal-actual-size"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_object_name_empty() {
|
||||
let result = ObjectKeyMapper::validate_object_name("");
|
||||
|
||||
+111
-11
@@ -138,6 +138,12 @@ impl std::fmt::Display for InternodeHttpErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, Clone, Copy, Eq, PartialEq)]
|
||||
#[error("internode body stalled for {timeout:?}")]
|
||||
pub struct BodyStalled {
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct InternodeHttpRequestContext {
|
||||
method: String,
|
||||
@@ -271,6 +277,10 @@ pub fn internode_http_timeout_error(method: &Method, url: &str) -> io::Error {
|
||||
internode_kind_error(method, url, internode_rpc_operation(url), InternodeHttpErrorKind::ConnectTimeout)
|
||||
}
|
||||
|
||||
fn body_stalled_error(stall_timeout: Duration) -> io::Error {
|
||||
Error::new(io::ErrorKind::TimedOut, BodyStalled { timeout: stall_timeout })
|
||||
}
|
||||
|
||||
/// Clone an internode HTTP I/O error while retaining its structured classification.
|
||||
///
|
||||
/// The underlying transport source is intentionally omitted because it is not
|
||||
@@ -705,6 +715,13 @@ async fn get_http_client(url: &str) -> io::Result<Client> {
|
||||
Ok(cached.client_for(disable_proxy))
|
||||
}
|
||||
|
||||
async fn get_fresh_http_client(url: &str) -> io::Result<Client> {
|
||||
let tuning = internode_http_client_tuning();
|
||||
let disable_proxy = should_disable_proxy_for_url(url, tuning);
|
||||
let outbound_tls = crate::http_runtime_sources::outbound_tls_state().await;
|
||||
build_http_client(disable_proxy, tuning, &outbound_tls).await
|
||||
}
|
||||
|
||||
fn internode_request_context(method: &Method, url: &str, operation: Option<&'static str>) -> InternodeHttpRequestContext {
|
||||
let target = reqwest::Url::parse(url)
|
||||
.ok()
|
||||
@@ -952,6 +969,28 @@ impl HttpReader {
|
||||
Self::with_capacity_and_stall_timeout(url, method, headers, body, 0, stall_timeout).await
|
||||
}
|
||||
|
||||
pub async fn new_fresh_connection_with_stall_timeout(
|
||||
url: String,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = Self::open(&url, &method, &headers, body, stall_timeout, true).await?;
|
||||
Ok(Self {
|
||||
inner: StreamReader::new(init.stream),
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
track_internode_metrics: init.track_internode_metrics,
|
||||
internode_operation: init.internode_operation,
|
||||
stall_timer: None,
|
||||
stall_timeout: init.stall_timeout,
|
||||
request_started: init.request_started,
|
||||
duration_recorded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new HttpReader from a URL. The request is performed immediately.
|
||||
pub async fn with_capacity(
|
||||
url: String,
|
||||
@@ -971,7 +1010,7 @@ impl HttpReader {
|
||||
_read_buf_size: usize,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = Self::open(&url, &method, &headers, body, stall_timeout).await?;
|
||||
let init = Self::open(&url, &method, &headers, body, stall_timeout, false).await?;
|
||||
Ok(Self {
|
||||
inner: StreamReader::new(init.stream),
|
||||
url,
|
||||
@@ -992,10 +1031,16 @@ impl HttpReader {
|
||||
headers: &HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
force_fresh_connection: bool,
|
||||
) -> io::Result<HttpReaderInit> {
|
||||
let track_internode_metrics = is_internode_rpc_url(url);
|
||||
let internode_operation = internode_rpc_operation(url);
|
||||
let client = get_http_client(url).await.inspect_err(|_| {
|
||||
let client = if force_fresh_connection {
|
||||
get_fresh_http_client(url).await
|
||||
} else {
|
||||
get_http_client(url).await
|
||||
}
|
||||
.inspect_err(|_| {
|
||||
record_internode_error(track_internode_metrics, internode_operation);
|
||||
})?;
|
||||
let mut request: RequestBuilder = client.request(method.clone(), url).headers(headers.clone());
|
||||
@@ -1085,10 +1130,7 @@ impl AsyncRead for HttpReader {
|
||||
);
|
||||
record_internode_stall_timeout(*this.track_internode_metrics, *this.internode_operation);
|
||||
record_internode_error(*this.track_internode_metrics, *this.internode_operation);
|
||||
Poll::Ready(Err(Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"HttpReader stall timeout: no data received before deadline",
|
||||
)))
|
||||
Poll::Ready(Err(body_stalled_error(stall_timeout)))
|
||||
} else {
|
||||
Poll::Pending
|
||||
}
|
||||
@@ -1114,7 +1156,28 @@ impl HttpChunkReader {
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout).await?;
|
||||
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout, false).await?;
|
||||
Ok(Self {
|
||||
inner: init.stream,
|
||||
current: None,
|
||||
track_internode_metrics: init.track_internode_metrics,
|
||||
internode_operation: init.internode_operation,
|
||||
stall_timer: None,
|
||||
stall_timeout: init.stall_timeout,
|
||||
request_started: init.request_started,
|
||||
duration_recorded: false,
|
||||
consecutive_empty_chunks: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new_fresh_connection_with_stall_timeout(
|
||||
url: String,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Option<Vec<u8>>,
|
||||
stall_timeout: Option<Duration>,
|
||||
) -> io::Result<Self> {
|
||||
let init = HttpReader::open(&url, &method, &headers, body, stall_timeout, true).await?;
|
||||
Ok(Self {
|
||||
inner: init.stream,
|
||||
current: None,
|
||||
@@ -1217,10 +1280,7 @@ impl ChunkReader for HttpChunkReader {
|
||||
);
|
||||
record_internode_stall_timeout(*this.track_internode_metrics, *this.internode_operation);
|
||||
record_internode_error(*this.track_internode_metrics, *this.internode_operation);
|
||||
return Poll::Ready(Err(Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"HttpReader stall timeout: no data received before deadline",
|
||||
)));
|
||||
return Poll::Ready(Err(body_stalled_error(stall_timeout)));
|
||||
}
|
||||
return Poll::Pending;
|
||||
}
|
||||
@@ -2379,6 +2439,46 @@ mod tests {
|
||||
Err(err) => err,
|
||||
};
|
||||
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
|
||||
let stalled = err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<BodyStalled>())
|
||||
.expect("stall timeout should retain typed body-stalled source");
|
||||
assert_eq!(stalled.timeout, Duration::from_millis(20));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_chunk_reader_stall_timeout_retains_typed_source() {
|
||||
let state = TestState::default();
|
||||
let Some((base_url, handle)) = start_test_server(state).await else {
|
||||
return;
|
||||
};
|
||||
let url = base_url.replace("/stream", "/stall");
|
||||
let mut reader =
|
||||
HttpChunkReader::new_with_stall_timeout(url, Method::GET, HeaderMap::new(), None, Some(Duration::from_millis(20)))
|
||||
.await
|
||||
.expect("chunk reader should open");
|
||||
|
||||
let first = std::future::poll_fn(|cx| Pin::new(&mut reader).poll_read_chunk(cx, 64))
|
||||
.await
|
||||
.expect("initial body chunk should arrive")
|
||||
.expect("initial body chunk should not be EOF");
|
||||
assert_eq!(first, b"hello"[..]);
|
||||
|
||||
let err = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
std::future::poll_fn(|cx| Pin::new(&mut reader).poll_read_chunk(cx, 64)),
|
||||
)
|
||||
.await
|
||||
.expect("stall timeout should wake chunk reader")
|
||||
.expect_err("chunk reader should return a timeout error");
|
||||
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
|
||||
let stalled = err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<BodyStalled>())
|
||||
.expect("chunk stall timeout should retain typed body-stalled source");
|
||||
assert_eq!(stalled.timeout, Duration::from_millis(20));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ for later deletion.
|
||||
- `table-catalog-strong-snapshot-v1` durable strong catalog snapshot compatibility: version 1 writes continue during mixed-version rollout until operators confirm that every serving node reads version 2, and version 1 table/view identifier collisions remain available only for cleanup. Remove version 1 writes and collision cleanup after the minimum supported RustFS release reads version 2 and every retained durable strong snapshot is collision-free and has been upgraded to version 2.
|
||||
- `table-catalog-migration-fence-v1` durable strong migration fence compatibility: version 1 "PREPARING" fences did not distinguish a known-absent global strong snapshot from an unknown baseline, so retries read them but fail closed if the global snapshot is missing. Version 2 preserves the same JSON shape and records the pre-migration global snapshot ETag in the existing target_snapshot_etag field while the fence is "PREPARING". Remove version 1 reads after every supported direct-upgrade source writes version 2 fences and operators have completed or cancelled every older in-progress backing migration.
|
||||
- `table-catalog-backing-manifest-v1-wire-labels` durable strong backing manifest labels: version 1 published "STRONG_KV_WAL" and "CUT_OVER_LINEARIZABLE_READS" before the implementation was narrowed to the ETag-CAS durable snapshot backing. Internal names and operator documentation describe the implemented semantics, while version 1 responses retain those labels for existing clients. Replace the labels only in a new manifest version with an explicit client migration contract.
|
||||
- `cross-pool-fence-v1` authenticated unsupported advertisement: predeployment servers recognize the versioned cross-pool fence capability probe but report support version 0, allowing a later all-peer probe to distinguish predeployment nodes without activating a second lock domain. Replace the unsupported advertisement only when composite lock acquisition, a cluster-wide activation fence, complete fleet proof, commit-time proof revalidation, and fail-closed revocation ship together.
|
||||
- `replacement-recovery-status-v1` replacement recovery status capability: new peers treat an unimplemented ReplacementRecoveryStatus RPC as an explicitly non-definitive rolling-upgrade response, so Admin v4 cannot claim distributed replacement completion from old peers. Remove the fallback after the minimum supported RustFS peer version implements ReplacementRecoveryStatus.
|
||||
- `table-catalog-dotted-namespace` Iceberg REST namespace path compatibility: existing RustFS clients use dotted namespace paths, while the standard multi-level contract uses the URL-encoded unit separator `%1F`. New servers accept both forms so a rolling upgrade does not invalidate existing catalog configuration. Remove the dotted fallback after the minimum supported RustFS release advertises `%1F` and all supported clients have refreshed their catalog configuration.
|
||||
- `rustfs-5509` FileInfo positional MessagePack decoding: beta.11 serialized 28 fields, while beta.12 inserted transition-version fields in the middle and serialized an incompatible 30-field array. New releases write named maps and retain readers for both shipped array layouts so direct and rolling upgrades can read either release. Remove the positional-array readers after every supported direct-upgrade release writes named maps and no retained RPC payload can contain a pre-map FileInfo array.
|
||||
|
||||
@@ -465,16 +465,6 @@ impl Operation for ImportBucketMetadata {
|
||||
file_contents.push((file_path, content));
|
||||
}
|
||||
|
||||
let durable_quota_import = imported_quota_requires_fleet_proof(&file_contents)?;
|
||||
let quota_fleet_proof =
|
||||
if durable_quota_import {
|
||||
Some(crate::admin::storage_api::acquire_cross_pool_fence_fleet_proof().ok_or_else(|| {
|
||||
s3_error!(ServiceUnavailable, "durable quota capability is not confirmed across the cluster")
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Extract bucket names
|
||||
let mut bucket_names = Vec::new();
|
||||
for (file_path, _) in &file_contents {
|
||||
@@ -717,6 +707,21 @@ impl Operation for ImportBucketMetadata {
|
||||
}
|
||||
|
||||
BUCKET_QUOTA_CONFIG_FILE => {
|
||||
if let Err(e) = serde_json::from_slice::<BucketQuota>(&content) {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_BUCKET_META_STATE,
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET_META,
|
||||
action = "import_bucket_metadata",
|
||||
result = "config_deserialize_failed",
|
||||
bucket = %bucket_name,
|
||||
config_name = %conf_name,
|
||||
error = %e,
|
||||
"admin bucket meta state"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match bucket_metadatas.get_mut(bucket_name) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
@@ -825,6 +830,10 @@ impl Operation for ImportBucketMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the assembled metadata to disk. Prior to this, the import only mutated the
|
||||
// in-memory `bucket_metadatas` map and returned 200, silently dropping every imported
|
||||
// config. `metadata_sys::update` loads the on-disk metadata, overwrites the given config
|
||||
// field and saves it, preserving any configs not present in the import archive.
|
||||
for (bucket_name, metadata) in &bucket_metadatas {
|
||||
for (config_file, data) in imported_configs_to_persist(metadata) {
|
||||
let site_replication_item = imported_config_to_site_replication_item(bucket_name, metadata, config_file, &data)?;
|
||||
@@ -833,21 +842,7 @@ impl Operation for ImportBucketMetadata {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let persist_result = if config_file == BUCKET_QUOTA_CONFIG_FILE {
|
||||
let quota: BucketQuota =
|
||||
serde_json::from_slice(&data).map_err(|e| s3_error!(InvalidRequest, "invalid bucket quota: {e}"))?;
|
||||
if quota.uses_durable_reservations() {
|
||||
let proof = quota_fleet_proof.as_ref().ok_or_else(|| {
|
||||
s3_error!(ServiceUnavailable, "durable quota capability is not confirmed across the cluster")
|
||||
})?;
|
||||
metadata_sys::update_quota_if_incarnation(bucket_name, data, metadata.bucket_incarnation_id, proof).await
|
||||
} else {
|
||||
metadata_sys::update_if_incarnation(bucket_name, config_file, data, metadata.bucket_incarnation_id).await
|
||||
}
|
||||
} else {
|
||||
metadata_sys::update_if_incarnation(bucket_name, config_file, data, metadata.bucket_incarnation_id).await
|
||||
};
|
||||
if let Err(e) = persist_result {
|
||||
if let Err(e) = metadata_sys::update(bucket_name, config_file, data).await {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_BUCKET_META_STATE,
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
@@ -890,26 +885,6 @@ impl Operation for ImportBucketMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
fn imported_quota_requires_fleet_proof(file_contents: &[(String, Vec<u8>)]) -> S3Result<bool> {
|
||||
let mut durable = false;
|
||||
for (file_path, content) in file_contents {
|
||||
let mut parts = file_path.split(SLASH_SEPARATOR);
|
||||
let Some(_bucket) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
if parts.next() != Some(BUCKET_QUOTA_CONFIG_FILE) {
|
||||
continue;
|
||||
}
|
||||
let quota: BucketQuota =
|
||||
serde_json::from_slice(content).map_err(|e| s3_error!(InvalidRequest, "invalid bucket quota: {e}"))?;
|
||||
if quota.has_unsupported_reservation_protocol() {
|
||||
return Err(s3_error!(InvalidRequest, "unsupported bucket quota reservation protocol"));
|
||||
}
|
||||
durable |= quota.uses_durable_reservations();
|
||||
}
|
||||
Ok(durable)
|
||||
}
|
||||
|
||||
/// The `(config_file, data)` pairs to persist for an imported bucket's metadata: every non-empty
|
||||
/// config field keyed by its on-disk config-file name, as owned data ready for
|
||||
/// `metadata_sys::update`. Empty fields are skipped so an import never overwrites an existing
|
||||
@@ -1101,31 +1076,4 @@ mod import_persist_tests {
|
||||
|
||||
assert!(!has_site_replication_item);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_import_preflight_rejects_invalid_and_unknown_protocols() {
|
||||
let missing_limit = vec![(
|
||||
format!("bucket/{BUCKET_QUOTA_CONFIG_FILE}"),
|
||||
br#"{"quota":0,"reservation_protocol":1}"#.to_vec(),
|
||||
)];
|
||||
assert!(imported_quota_requires_fleet_proof(&missing_limit).is_err());
|
||||
|
||||
let unknown_protocol = vec![(
|
||||
format!("bucket/{BUCKET_QUOTA_CONFIG_FILE}"),
|
||||
br#"{"quota":0,"reservation_protocol":2,"reservation_quota":1024}"#.to_vec(),
|
||||
)];
|
||||
assert!(imported_quota_requires_fleet_proof(&unknown_protocol).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_import_preflight_requires_proof_only_for_durable_quota() {
|
||||
let legacy = vec![(format!("bucket/{BUCKET_QUOTA_CONFIG_FILE}"), br#"{"quota":1024}"#.to_vec())];
|
||||
assert!(!imported_quota_requires_fleet_proof(&legacy).expect("legacy quota should remain compatible"));
|
||||
|
||||
let durable = vec![(
|
||||
format!("bucket/{BUCKET_QUOTA_CONFIG_FILE}"),
|
||||
serde_json::to_vec(&BucketQuota::new(Some(1024))).expect("durable quota should encode"),
|
||||
)];
|
||||
assert!(imported_quota_requires_fleet_proof(&durable).expect("durable quota should pass preflight"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::admin::storage_api::bucket::metadata_sys::{self, BucketMetadataSys};
|
||||
use crate::admin::storage_api::bucket::quota::checker::QuotaChecker;
|
||||
use crate::admin::storage_api::bucket::quota::{BucketQuota, QuotaError, QuotaOperation};
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::error::ApiError;
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use hyper::{Method, StatusCode};
|
||||
use matchit::Params;
|
||||
@@ -294,36 +293,16 @@ impl Operation for SetBucketQuotaHandler {
|
||||
return Err(s3_error!(InvalidArgument, "{}", rustfs_config::QUOTA_INVALID_TYPE_ERROR_MSG));
|
||||
}
|
||||
|
||||
let fleet_proof = if request.quota.is_some() {
|
||||
Some(crate::admin::storage_api::acquire_cross_pool_fence_fleet_proof().ok_or_else(|| {
|
||||
S3Error::with_message(
|
||||
s3s::S3ErrorCode::ServiceUnavailable,
|
||||
"durable quota capability is not confirmed across the cluster".to_string(),
|
||||
)
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let quota = BucketQuota::new(request.quota);
|
||||
|
||||
let metadata_sys_lock = bucket_metadata_from_context()
|
||||
.ok_or_else(|| s3_error!(InternalError, "{}", rustfs_config::QUOTA_METADATA_SYSTEM_ERROR_MSG))?;
|
||||
let mut quota_checker = QuotaChecker::new(metadata_sys_lock.clone());
|
||||
|
||||
let updated_at = match fleet_proof.as_ref() {
|
||||
Some(fleet_proof) => {
|
||||
quota_checker
|
||||
.set_durable_quota_config_if_incarnation(&bucket, quota.clone(), expected_incarnation_id, fleet_proof)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
quota_checker
|
||||
.set_quota_config_if_incarnation(&bucket, quota.clone(), expected_incarnation_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(ApiError::from)?;
|
||||
let updated_at = quota_checker
|
||||
.set_quota_config_if_incarnation(&bucket, quota.clone(), expected_incarnation_id)
|
||||
.await
|
||||
.map_err(|e| s3_error!(InternalError, "failed to set quota: {}", e))?;
|
||||
|
||||
if let Err(err) = site_replication_bucket_meta_hook(SRBucketMeta {
|
||||
bucket: bucket.clone(),
|
||||
|
||||
@@ -29,7 +29,6 @@ use crate::admin::storage_api::bucket::metadata::{
|
||||
BUCKET_SSECONFIG, BUCKET_TAGGING_CONFIG, BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, OBJECT_LOCK_CONFIG,
|
||||
};
|
||||
use crate::admin::storage_api::bucket::metadata_sys;
|
||||
use crate::admin::storage_api::bucket::quota::BucketQuota;
|
||||
use crate::admin::storage_api::bucket::replication;
|
||||
use crate::admin::storage_api::bucket::target::{ARN, BucketTarget, BucketTargetType, BucketTargets, Credentials};
|
||||
use crate::admin::storage_api::bucket::target_sys::BucketTargetSys;
|
||||
@@ -7896,35 +7895,9 @@ async fn apply_bucket_meta_item(item: SRBucketMeta) -> S3Result<()> {
|
||||
|
||||
if !skip_config_write {
|
||||
if let Some(data) = data {
|
||||
if item.r#type == "quota-config" {
|
||||
let quota: BucketQuota = serde_json::from_slice(&data)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("invalid bucket quota: {e}")))?;
|
||||
if quota.has_unsupported_reservation_protocol() {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
"unsupported bucket quota reservation protocol".to_string(),
|
||||
));
|
||||
}
|
||||
if quota.uses_durable_reservations() {
|
||||
let proof = crate::admin::storage_api::acquire_cross_pool_fence_fleet_proof().ok_or_else(|| {
|
||||
S3Error::with_message(
|
||||
S3ErrorCode::ServiceUnavailable,
|
||||
"durable quota capability is not confirmed across the cluster".to_string(),
|
||||
)
|
||||
})?;
|
||||
metadata_sys::update_quota_if_incarnation(&item.bucket, data, expected_incarnation_id, &proof)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
} else {
|
||||
metadata_sys::update_if_incarnation(&item.bucket, config_file, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
}
|
||||
} else {
|
||||
metadata_sys::update_if_incarnation(&item.bucket, config_file, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
}
|
||||
metadata_sys::update_if_incarnation(&item.bucket, config_file, data, expected_incarnation_id)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
} else {
|
||||
metadata_sys::delete_if_incarnation(&item.bucket, config_file, expected_incarnation_id)
|
||||
.await
|
||||
|
||||
@@ -2901,7 +2901,7 @@ async fn handle_misc_extension_request(req: &mut S3Request<Body>, route: &MiscEx
|
||||
MiscExtRoute::ObjectLambda { bucket, object } => {
|
||||
let get_req = build_object_lambda_get_request(req, bucket, object)?;
|
||||
let usecase = default_object_usecase();
|
||||
let get_resp = usecase.execute_get_object(get_req).await?;
|
||||
let get_resp = Box::pin(usecase.execute_get_object(get_req)).await?;
|
||||
invoke_object_lambda_target(req, bucket, object, get_resp).await
|
||||
}
|
||||
MiscExtRoute::ListenNotification { bucket } => {
|
||||
|
||||
@@ -64,9 +64,7 @@ mod ecstore_metrics {
|
||||
}
|
||||
|
||||
mod ecstore_notification {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_notification::{
|
||||
CrossPoolFenceFleetProofToken, NotificationSys, acquire_cross_pool_fence_fleet_proof,
|
||||
};
|
||||
pub(crate) use crate::storage::storage_api::ecstore_notification::NotificationSys;
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
@@ -114,10 +112,6 @@ pub(crate) type TierCreds = ecstore_tier::tier_admin::TierCreds;
|
||||
pub(crate) type TierType = ecstore_tier::tier_config::TierType;
|
||||
pub(crate) type TierConfigUpdateError = crate::storage::storage_api::TierConfigUpdateError;
|
||||
|
||||
pub(crate) fn acquire_cross_pool_fence_fleet_proof() -> Option<ecstore_notification::CrossPoolFenceFleetProofToken> {
|
||||
ecstore_notification::acquire_cross_pool_fence_fleet_proof()
|
||||
}
|
||||
|
||||
pub(crate) mod runtime_sources {
|
||||
pub(crate) type DailyAllTierStats = super::DailyAllTierStats;
|
||||
pub(crate) type ECStore = super::ECStore;
|
||||
@@ -302,15 +296,6 @@ pub(crate) mod metadata_sys {
|
||||
super::ecstore_bucket::metadata_sys::update_if_incarnation(bucket, config_file, data, expected_incarnation_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_quota_if_incarnation(
|
||||
bucket: &str,
|
||||
data: Vec<u8>,
|
||||
expected_incarnation_id: uuid::Uuid,
|
||||
proof: &super::ecstore_notification::CrossPoolFenceFleetProofToken,
|
||||
) -> Result<OffsetDateTime> {
|
||||
super::ecstore_bucket::metadata_sys::update_quota_if_incarnation(bucket, data, expected_incarnation_id, proof).await
|
||||
}
|
||||
|
||||
pub(crate) async fn capture_bucket_metadata_incarnation(bucket: &str) -> Result<uuid::Uuid> {
|
||||
super::ecstore_bucket::metadata_sys::capture_bucket_metadata_incarnation(bucket).await
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
//! `ECStore` and one metadata-sys initialization exist per test binary.
|
||||
|
||||
use super::storage_api::test::bucket::metadata_sys;
|
||||
use super::storage_api::test::bucket::quota::BucketQuota;
|
||||
use super::storage_api::test::bucket::quota::checker::QuotaChecker;
|
||||
use super::storage_api::test::contract::bucket::MakeBucketOptions;
|
||||
use super::storage_api::test::contract::bucket::{BucketOperations, BucketOptions};
|
||||
use super::storage_api::test::{ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use super::{context::AppContext, object_traffic_health::ObjectTrafficHealth};
|
||||
@@ -90,17 +87,6 @@ pub(crate) async fn shared_gating_ecstore() -> Arc<ECStore> {
|
||||
crate::storage::storage_api::new_global_notification_sys(endpoint_pools.clone())
|
||||
.await
|
||||
.expect("initialize notification system for gating test env");
|
||||
let topology_fingerprint =
|
||||
crate::storage::storage_api::heal_control_startup_consumer::heal_topology_fingerprint(&endpoint_pools)
|
||||
.expect("single-node gating topology should hash");
|
||||
crate::storage::storage_api::start_remote_version_state_fleet_probe(topology_fingerprint);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while crate::storage::storage_api::ecstore_notification::acquire_cross_pool_fence_fleet_proof().is_none() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("single-node cross-pool fence capability proof should publish");
|
||||
|
||||
let server_addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
|
||||
@@ -121,24 +107,6 @@ pub(crate) async fn shared_gating_ecstore() -> Arc<ECStore> {
|
||||
ecstore
|
||||
}
|
||||
|
||||
pub(crate) async fn durable_quota_test_bucket(prefix: &str, limit: u64) -> (Arc<ECStore>, String) {
|
||||
let store = shared_gating_ecstore().await;
|
||||
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
||||
let bucket = format!("{prefix:.30}-{}", uuid::Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create durable quota test bucket");
|
||||
super::storage_api::test::data_usage::seed_bucket_usage_memory_for_test(&bucket, 0).await;
|
||||
let metadata_sys =
|
||||
crate::app::storage_api::test::get_global_bucket_metadata_sys().expect("test app context should expose bucket metadata");
|
||||
QuotaChecker::new(metadata_sys)
|
||||
.set_quota_config(&bucket, BucketQuota::new(Some(limit)))
|
||||
.await
|
||||
.expect("configure durable quota test bucket");
|
||||
(store, bucket)
|
||||
}
|
||||
|
||||
pub(crate) async fn shared_gating_ambient() -> Arc<AppContext> {
|
||||
let store = shared_gating_ecstore().await;
|
||||
if let Some(ambient) = crate::runtime_sources::current_app_context() {
|
||||
|
||||
@@ -33,7 +33,7 @@ use super::storage_api::multipart_usecase::contract::multipart::{CompletePart, M
|
||||
use super::storage_api::multipart_usecase::contract::object::{ObjectIO as _, ObjectOperations as _};
|
||||
use super::storage_api::multipart_usecase::contract::range::HTTPRangeSpec;
|
||||
use super::storage_api::multipart_usecase::data_usage::{
|
||||
quota_object_size, record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
};
|
||||
use super::storage_api::multipart_usecase::error::{StorageError, is_err_object_not_found, is_err_version_not_found};
|
||||
use super::storage_api::multipart_usecase::helper::OperationHelper;
|
||||
@@ -64,10 +64,10 @@ use super::storage_api::multipart_usecase::{
|
||||
};
|
||||
use crate::app::object_data_cache::{
|
||||
ObjectDataCacheAdapter, invalidate_object_data_cache_after_complete_multipart_success,
|
||||
invalidate_object_data_cache_before_mutation,
|
||||
invalidate_object_data_cache_after_delete_success, invalidate_object_data_cache_before_mutation,
|
||||
};
|
||||
use crate::app::object_usecase::{
|
||||
acquire_copy_bucket_lifecycle_locks, apply_quota_admission, build_put_like_object_lock_metadata, map_quota_check_outcome,
|
||||
acquire_copy_bucket_lifecycle_locks, build_put_like_object_lock_metadata, map_quota_check_outcome,
|
||||
validate_existing_object_lock_for_write,
|
||||
};
|
||||
use crate::app::runtime_sources::{
|
||||
@@ -83,8 +83,6 @@ use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use rustfs_targets::EventName;
|
||||
use rustfs_utils::CompressionAlgorithm;
|
||||
#[cfg(test)]
|
||||
use rustfs_utils::http::insert_header;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP,
|
||||
SUFFIX_SOURCE_REPLICATION_REQUEST, contains_key_str, get_header, get_source_scheme,
|
||||
@@ -228,8 +226,12 @@ fn internal_object_info_lookup_opts(mut opts: ObjectOptions) -> ObjectOptions {
|
||||
opts
|
||||
}
|
||||
|
||||
fn logical_object_size(info: &ObjectInfo) -> Result<u64, StorageError> {
|
||||
u64::try_from(info.get_actual_size()?).map_err(|_| StorageError::PartMissingOrCorrupt)
|
||||
}
|
||||
|
||||
fn quota_accounting_object_size(info: &ObjectInfo, fail_closed: bool) -> S3Result<u64> {
|
||||
match quota_object_size(info) {
|
||||
match logical_object_size(info) {
|
||||
Ok(size) => Ok(size),
|
||||
Err(err) if fail_closed => Err(ApiError::from(err).into()),
|
||||
Err(_) => Ok(info.size.max(0) as u64),
|
||||
@@ -505,7 +507,11 @@ impl DefaultMultipartUsecase {
|
||||
Ok(existing_obj_info) => {
|
||||
validate_existing_object_lock_for_write(&existing_obj_info, ¤t_opts)?;
|
||||
let physical_size = existing_obj_info.size.max(0) as u64;
|
||||
let logical_size = quota_object_size(&existing_obj_info);
|
||||
let logical_size = if opts.replication_request {
|
||||
Ok(physical_size)
|
||||
} else {
|
||||
logical_object_size(&existing_obj_info)
|
||||
};
|
||||
Some((physical_size, logical_size))
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -554,20 +560,32 @@ impl DefaultMultipartUsecase {
|
||||
};
|
||||
|
||||
let quota_metadata_sys = self.bucket_metadata_sys();
|
||||
let quota_tracking = quota_metadata_sys.is_some();
|
||||
let mut quota_enabled = false;
|
||||
if let Some(metadata_sys) = quota_metadata_sys.as_ref() {
|
||||
let quota_checker = QuotaChecker::new(metadata_sys.clone());
|
||||
let check_result =
|
||||
map_quota_check_outcome(&bucket, quota_checker.check_quota(&bucket, QuotaOperation::PutObject, 0).await)?;
|
||||
quota_enabled = check_result.quota_limit.is_some();
|
||||
apply_quota_admission(&mut opts, &check_result)?;
|
||||
// Ciphertext-passthrough replication parts use a different size basis and retain
|
||||
// the existing post-commit accounting path until they carry a trusted logical-size proof.
|
||||
if !opts.replication_request
|
||||
&& let Some(quota_limit) = check_result.quota_limit
|
||||
{
|
||||
let installed = check_result
|
||||
.current_usage
|
||||
.is_some_and(|current_usage| opts.set_quota_admission(current_usage, quota_limit));
|
||||
if !installed {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::ServiceUnavailable,
|
||||
"Bucket quota check temporarily unavailable, please retry".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let previous_current_size = match previous_current_sizes {
|
||||
Some((_, Ok(logical_size))) if quota_enabled => Some(logical_size),
|
||||
Some((_, Err(err))) if quota_enabled => return Err(ApiError::from(err).into()),
|
||||
Some((physical_size, _)) => Some(physical_size),
|
||||
Some((physical_size, _)) if opts.replication_request => Some(physical_size),
|
||||
Some((_, Ok(logical_size))) => Some(logical_size),
|
||||
Some((_, Err(err))) if opts.quota_admission.is_some() => return Err(ApiError::from(err).into()),
|
||||
Some((physical_size, Err(_))) => Some(physical_size),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -577,6 +595,7 @@ impl DefaultMultipartUsecase {
|
||||
let key = key.clone();
|
||||
let upload_id = upload_id.clone();
|
||||
let opts = opts.clone();
|
||||
let quota_metadata_sys = quota_metadata_sys.clone();
|
||||
async move {
|
||||
let obj_info = store
|
||||
.clone()
|
||||
@@ -586,9 +605,37 @@ impl DefaultMultipartUsecase {
|
||||
let _ = invalidate_object_data_cache_after_complete_multipart_success(&cache_adapter, &bucket, &key).await;
|
||||
record_capacity_write(Some(capacity_scope_token)).await;
|
||||
|
||||
if quota_tracking {
|
||||
let committed_size = quota_accounting_object_size(&obj_info, quota_enabled)?;
|
||||
if let Some(metadata_sys) = quota_metadata_sys.as_ref() {
|
||||
if opts.replication_request {
|
||||
let quota_checker = QuotaChecker::new(metadata_sys.clone());
|
||||
match quota_checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, obj_info.size.max(0) as u64)
|
||||
.await
|
||||
{
|
||||
Ok(check_result) if !check_result.allowed => {
|
||||
let _ = store.delete_object(&bucket, &key, ObjectOptions::default()).await;
|
||||
let _ = invalidate_object_data_cache_after_delete_success(&cache_adapter, &bucket, &key).await;
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
format!(
|
||||
"Bucket quota exceeded. Current usage: {} bytes, limit: {} bytes",
|
||||
check_result.current_usage.unwrap_or(0),
|
||||
check_result.quota_limit.unwrap_or(0)
|
||||
),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Quota check failed for bucket {} after multipart completion: {}", bucket, err);
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let committed_size = if opts.replication_request {
|
||||
obj_info.size.max(0) as u64
|
||||
} else {
|
||||
quota_accounting_object_size(&obj_info, opts.quota_admission.is_some())?
|
||||
};
|
||||
if versioned {
|
||||
record_bucket_object_version_write_memory(&bucket, previous_current_size, committed_size).await;
|
||||
} else {
|
||||
@@ -798,20 +845,6 @@ impl DefaultMultipartUsecase {
|
||||
let ciphertext_passthrough = replication_authorized
|
||||
&& get_header(&req.headers, SUFFIX_SOURCE_REPLICATION_REQUEST).as_deref() == Some("true")
|
||||
&& rustfs_utils::http::ssec_transport_to_stored_metadata(&req.headers).is_some();
|
||||
if ciphertext_passthrough && let Some(metadata_sys) = self.bucket_metadata_sys() {
|
||||
let check_result = map_quota_check_outcome(
|
||||
&bucket,
|
||||
QuotaChecker::new(metadata_sys)
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, 0)
|
||||
.await,
|
||||
)?;
|
||||
if check_result.quota_limit.is_some() {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
"SSE-C ciphertext replication is unavailable for quota-enabled buckets".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if ciphertext_passthrough {
|
||||
insert_str(&mut metadata, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, "true".to_string());
|
||||
}
|
||||
@@ -1645,24 +1678,6 @@ mod tests {
|
||||
|
||||
assert_eq!(quota_accounting_object_size(&info, true).expect("logical size should resolve"), 8192);
|
||||
assert_eq!(quota_accounting_object_size(&info, false).expect("logical size should resolve"), 8192);
|
||||
|
||||
let mut poisoned_metadata = HashMap::new();
|
||||
insert_str(&mut poisoned_metadata, rustfs_utils::http::SUFFIX_COMPRESSION, "S2".to_string());
|
||||
insert_str(&mut poisoned_metadata, rustfs_utils::http::SUFFIX_ACTUAL_SIZE, "1".to_string());
|
||||
let poisoned = ObjectInfo {
|
||||
size: 17,
|
||||
parts: Arc::new(vec![rustfs_filemeta::ObjectPartInfo {
|
||||
size: 4096,
|
||||
actual_size: 4096,
|
||||
..Default::default()
|
||||
}]),
|
||||
user_defined: Arc::new(poisoned_metadata),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
quota_accounting_object_size(&poisoned, true).expect("persisted part size must be charged"),
|
||||
4096
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2071,14 +2086,30 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn compressed_complete_records_logical_quota_usage_and_overwrite_delta() {
|
||||
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("compressed-complete-quota", 16_384).await;
|
||||
use crate::app::storage_api::multipart_usecase::bucket::quota::BucketQuota;
|
||||
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::app::storage_api::test::data_usage::seed_bucket_usage_memory_for_test;
|
||||
|
||||
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
|
||||
crate::app::runtime_sources::install_test_app_context(Arc::clone(&store)).await;
|
||||
|
||||
let bucket = format!("compressed-complete-quota-{}", Uuid::new_v4());
|
||||
let object = "object";
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create compressed quota bucket");
|
||||
seed_bucket_usage_memory_for_test(&bucket, 0).await;
|
||||
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
let metadata_sys = usecase
|
||||
.bucket_metadata_sys()
|
||||
.expect("test app context should expose bucket metadata");
|
||||
let quota_checker = QuotaChecker::new(metadata_sys);
|
||||
let mut quota_checker = QuotaChecker::new(metadata_sys);
|
||||
quota_checker
|
||||
.set_quota_config(&bucket, BucketQuota::new(Some(16_384)))
|
||||
.await
|
||||
.expect("configure bucket quota");
|
||||
|
||||
for (actual_size, payload_byte) in [(8192_i64, 0x61), (4096_i64, 0x62)] {
|
||||
let mut create_opts = ObjectOptions::default();
|
||||
@@ -2123,173 +2154,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn create_multipart_rejects_ciphertext_replication_before_parts_are_staged() {
|
||||
let (_store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("ciphertext-multipart-quota", 4096).await;
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
let input = CreateMultipartUploadInput::builder()
|
||||
.bucket(bucket)
|
||||
.key("object".to_string())
|
||||
.build()
|
||||
.expect("create multipart request should build");
|
||||
let mut request = build_request(input, Method::POST);
|
||||
insert_header(&mut request.headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
|
||||
request
|
||||
.headers
|
||||
.insert(rustfs_utils::http::REPLICATION_SSEC_ALGORITHM_HEADER, HeaderValue::from_static("AES256"));
|
||||
request.extensions.insert(crate::storage::access::ReqInfo {
|
||||
replication_request_authorized: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let err = usecase
|
||||
.execute_create_multipart_upload(request)
|
||||
.await
|
||||
.expect_err("quota-enabled ciphertext multipart replication should fail before upload creation");
|
||||
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn concurrent_completions_share_durable_bucket_quota_reservations() {
|
||||
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("concurrent-complete-quota", 6000).await;
|
||||
|
||||
let usecase = DefaultMultipartUsecase::from_global();
|
||||
|
||||
let mut inputs = Vec::new();
|
||||
for object in ["first", "second"] {
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create concurrent multipart upload");
|
||||
let mut reader = PutObjReader::from_vec(vec![0x71; 4096]);
|
||||
let part = store
|
||||
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("stage concurrent multipart part");
|
||||
inputs.push(
|
||||
CompleteMultipartUploadInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key(object.to_string())
|
||||
.upload_id(upload.upload_id)
|
||||
.multipart_upload(Some(CompletedMultipartUpload {
|
||||
parts: Some(vec![CompletedPart {
|
||||
part_number: Some(1),
|
||||
e_tag: part.etag.map(|etag| to_s3s_etag(&etag)),
|
||||
..Default::default()
|
||||
}]),
|
||||
}))
|
||||
.build()
|
||||
.expect("build concurrent completion input"),
|
||||
);
|
||||
}
|
||||
|
||||
let first_usecase = usecase.clone();
|
||||
let first = first_usecase.execute_complete_multipart_upload(build_request(inputs.remove(0), Method::POST));
|
||||
let second = usecase.execute_complete_multipart_upload(build_request(inputs.remove(0), Method::POST));
|
||||
let (first, second) = tokio::join!(first, second);
|
||||
|
||||
assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1);
|
||||
let denied = first.err().or_else(|| second.err()).expect("one completion must be denied");
|
||||
assert_eq!(denied.code(), &S3ErrorCode::InvalidRequest);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn multipart_completion_rejects_rotated_quota_capability_before_rename() {
|
||||
use crate::app::storage_api::test::set_disk::{MultipartCommitBarrier, MultipartCommitPause};
|
||||
|
||||
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("rotated-proof-mpu-quota", 4096).await;
|
||||
let object = "object";
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create multipart upload");
|
||||
let mut reader = PutObjReader::from_vec(vec![0x78; 4096]);
|
||||
let part = store
|
||||
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("stage multipart part");
|
||||
let barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::BeforeQuotaRename);
|
||||
let complete_store = Arc::clone(&store);
|
||||
let complete_bucket = bucket.clone();
|
||||
let upload_id = upload.upload_id.clone();
|
||||
let complete = tokio::spawn(async move {
|
||||
complete_store
|
||||
.complete_multipart_upload(
|
||||
&complete_bucket,
|
||||
object,
|
||||
&upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: 1,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
assert!(
|
||||
crate::storage::storage_api::ecstore_notification::rotate_cross_pool_fence_fleet_proof_for_test(),
|
||||
"the gating environment must have a current fleet proof"
|
||||
);
|
||||
barrier.release();
|
||||
|
||||
let err = complete
|
||||
.await
|
||||
.expect("completion task should not panic")
|
||||
.expect_err("a replaced fleet proof must fence multipart rename");
|
||||
assert!(matches!(
|
||||
err,
|
||||
StorageError::NamespaceLockQuorumUnavailable {
|
||||
mode: "quota_reservation",
|
||||
..
|
||||
}
|
||||
));
|
||||
store
|
||||
.get_multipart_info(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("proof rotation must preserve the multipart upload for retry");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn data_movement_multipart_completion_has_zero_quota_growth() {
|
||||
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket("data-movement-mpu-quota", 0).await;
|
||||
let object = "object";
|
||||
let mut movement_opts = ObjectOptions {
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
};
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, object, &movement_opts)
|
||||
.await
|
||||
.expect("create data-movement multipart upload");
|
||||
let mut reader = PutObjReader::from_vec(vec![0x7a; 4096]);
|
||||
let part = store
|
||||
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut reader, &movement_opts)
|
||||
.await
|
||||
.expect("stage data-movement multipart part");
|
||||
movement_opts.preserve_etag = Some("movement-etag".to_string());
|
||||
let completed = store
|
||||
.complete_multipart_upload(
|
||||
&bucket,
|
||||
object,
|
||||
&upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: 1,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&movement_opts,
|
||||
)
|
||||
.await
|
||||
.expect("moving an already-accounted multipart object between pools must have zero quota growth");
|
||||
assert_eq!(completed.size, 4096);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn rejected_empty_parts_preserve_existing_object_and_staging() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,6 @@ pub(crate) mod capacity {
|
||||
pub(crate) mod data_usage {
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) use crate::storage::storage_api::ecstore_data_usage::quota_object_size;
|
||||
|
||||
pub(crate) async fn apply_bucket_usage_memory_overlay(data_usage_info: &mut rustfs_data_usage::DataUsageInfo) {
|
||||
crate::storage::storage_api::ecstore_data_usage::apply_bucket_usage_memory_overlay(data_usage_info).await;
|
||||
}
|
||||
@@ -1216,14 +1214,4 @@ pub(crate) mod test {
|
||||
pub(crate) use crate::storage::storage_api::{
|
||||
ECStore, Endpoint, Endpoints, PoolEndpoints, StorageObjectInfo, StorageObjectOptions, StoragePutObjReader,
|
||||
};
|
||||
pub(crate) mod set_disk {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_set_disk::{
|
||||
MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause,
|
||||
fail_next_quota_ledger_save_for_test,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) mod metadata_sys {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_bucket::metadata_sys::ConfigWriteLockProbe;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +669,23 @@ mod tests {
|
||||
assert!(api_error.source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_storage_io_copy_object_terminal_error_stays_internal() {
|
||||
let io_error = IoError::other(StorageError::FileCorrupt);
|
||||
let storage_error: StorageError = io_error.into();
|
||||
assert!(matches!(storage_error, StorageError::FileCorrupt));
|
||||
|
||||
let api_error: ApiError = storage_error.into();
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::InternalError);
|
||||
let source = api_error
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(matches!(source, StorageError::FileCorrupt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_error_from_iam_error() {
|
||||
let iam_error = rustfs_iam::error::Error::other("IAM test error");
|
||||
|
||||
@@ -301,7 +301,7 @@ impl S3 for FS {
|
||||
#[instrument(level = "debug", skip(self, req))]
|
||||
async fn copy_object(&self, req: S3Request<CopyObjectInput>) -> S3Result<S3Response<CopyObjectOutput>> {
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_copy_object(req).await
|
||||
Box::pin(usecase.execute_copy_object(req)).await
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
@@ -704,7 +704,7 @@ impl S3 for FS {
|
||||
async fn get_object(&self, req: S3Request<GetObjectInput>) -> S3Result<S3Response<GetObjectOutput>> {
|
||||
crate::hp_guard!("S3::get_object");
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_get_object(req).await
|
||||
Box::pin(usecase.execute_get_object(req)).await
|
||||
}
|
||||
|
||||
async fn get_object_acl(&self, req: S3Request<GetObjectAclInput>) -> S3Result<S3Response<GetObjectAclOutput>> {
|
||||
@@ -1261,7 +1261,7 @@ impl S3 for FS {
|
||||
async fn put_object(&self, req: S3Request<PutObjectInput>) -> S3Result<S3Response<PutObjectOutput>> {
|
||||
crate::hp_guard!("S3::put_object");
|
||||
let usecase = s3_api::object_usecase_for(self);
|
||||
usecase.execute_put_object(self, req).await
|
||||
Box::pin(usecase.execute_put_object(self, req)).await
|
||||
}
|
||||
|
||||
async fn put_object_acl(&self, req: S3Request<PutObjectAclInput>) -> S3Result<S3Response<PutObjectAclOutput>> {
|
||||
|
||||
@@ -153,7 +153,9 @@ 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 = 1;
|
||||
// RUSTFS_COMPAT_TODO(cross-pool-fence-v1): advertise unsupported during predeployment. Remove after composite acquisition,
|
||||
// activation fencing, fleet proof, commit-time proof revalidation, and fail-closed revocation ship together.
|
||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 0;
|
||||
|
||||
fn admit_heal_control_replay(
|
||||
replay_cache: &mut HashMap<String, Arc<HealControlReplayEntry>>,
|
||||
@@ -2943,7 +2945,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_lease_acquire_and_renew_handlers_fail_closed_for_missing_disk() {
|
||||
async fn snapshot_lease_acquire_and_renew_handlers_fail_closed() {
|
||||
let service = make_server();
|
||||
let disk = "http://node-a:9000/data/rustfs0".to_string();
|
||||
|
||||
@@ -2960,7 +2962,7 @@ mod tests {
|
||||
let acquire = service
|
||||
.acquire_snapshot_lease(acquire)
|
||||
.await
|
||||
.expect("missing-disk acquire should return a protocol response")
|
||||
.expect("disabled acquire should return a protocol response")
|
||||
.into_inner();
|
||||
|
||||
let mut renew = Request::new(SnapshotLeaseRenewRequest {
|
||||
@@ -2977,14 +2979,14 @@ mod tests {
|
||||
let renew = service
|
||||
.renew_snapshot_lease(renew)
|
||||
.await
|
||||
.expect("missing-disk renew should return a protocol response")
|
||||
.expect("disabled renew should return a protocol response")
|
||||
.into_inner();
|
||||
|
||||
for response in [acquire, renew] {
|
||||
assert!(!response.success);
|
||||
assert!(response.token.is_empty());
|
||||
assert_eq!(response.protocol_version, 1);
|
||||
assert_eq!(response.error, Some(DiskError::other("cannot find disk").into()));
|
||||
assert_eq!(response.error, Some(DiskError::UnsupportedDisk.into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3340,7 +3342,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_pool_fence_probe_authenticates_supported_v1_state() {
|
||||
async fn cross_pool_fence_probe_authenticates_unsupported_rollout_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!(
|
||||
@@ -3405,7 +3407,7 @@ mod tests {
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.error_info, None);
|
||||
assert_eq!(&response.result[..4], &1_u32.to_be_bytes());
|
||||
assert_eq!(&response.result[..4], &0_u32.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");
|
||||
|
||||
@@ -37,28 +37,19 @@ const MSGPACK_ENCODE_CAPACITY_HINT: usize = 512;
|
||||
const FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT: usize = 1024;
|
||||
const SNAPSHOT_LEASE_PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
fn snapshot_lease_response(result: Result<SnapshotLeaseToken, DiskError>) -> Response<SnapshotLeaseResponse> {
|
||||
match result {
|
||||
Ok(token) => Response::new(SnapshotLeaseResponse {
|
||||
success: true,
|
||||
token: token.as_bytes().to_vec().into(),
|
||||
protocol_version: SNAPSHOT_LEASE_PROTOCOL_VERSION,
|
||||
error: None,
|
||||
}),
|
||||
Err(err) => Response::new(SnapshotLeaseResponse {
|
||||
success: false,
|
||||
token: Bytes::new(),
|
||||
protocol_version: SNAPSHOT_LEASE_PROTOCOL_VERSION,
|
||||
error: Some(err.into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
struct DecodedRpcPayload<T> {
|
||||
value: T,
|
||||
from_msgpack: bool,
|
||||
}
|
||||
|
||||
fn snapshot_lease_disabled_response() -> SnapshotLeaseResponse {
|
||||
SnapshotLeaseResponse {
|
||||
success: false,
|
||||
token: Bytes::new(),
|
||||
protocol_version: SNAPSHOT_LEASE_PROTOCOL_VERSION,
|
||||
error: Some(DiskError::UnsupportedDisk.into()),
|
||||
}
|
||||
}
|
||||
fn decode_msgpack_or_json<T: DeserializeOwned>(
|
||||
binary: &[u8],
|
||||
json: &str,
|
||||
@@ -250,12 +241,7 @@ impl NodeService {
|
||||
rustfs_protos::canonical_snapshot_lease_request_body(request.get_ref()),
|
||||
"acquire_snapshot_lease",
|
||||
)?;
|
||||
let request = request.into_inner();
|
||||
let result = match self.find_disk(&request.disk).await {
|
||||
Some(disk) => disk.acquire_snapshot_lease(&request.volume, &request.path).await,
|
||||
None => Err(DiskError::other("cannot find disk")),
|
||||
};
|
||||
Ok(snapshot_lease_response(result))
|
||||
Ok(Response::new(snapshot_lease_disabled_response()))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_renew_snapshot_lease(
|
||||
@@ -267,14 +253,7 @@ impl NodeService {
|
||||
rustfs_protos::canonical_snapshot_lease_renew_request_body(request.get_ref()),
|
||||
"renew_snapshot_lease",
|
||||
)?;
|
||||
let request = request.into_inner();
|
||||
let token =
|
||||
SnapshotLeaseToken::from_slice(&request.token).map_err(|_| Status::invalid_argument("invalid lease token"))?;
|
||||
let result = match self.find_disk(&request.disk).await {
|
||||
Some(disk) => disk.renew_snapshot_lease(&request.volume, &request.path, token).await,
|
||||
None => Err(DiskError::other("cannot find disk")),
|
||||
};
|
||||
Ok(snapshot_lease_response(result))
|
||||
Ok(Response::new(snapshot_lease_disabled_response()))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_release_snapshot_lease(
|
||||
@@ -287,11 +266,8 @@ impl NodeService {
|
||||
"release_snapshot_lease",
|
||||
)?;
|
||||
let request = request.into_inner();
|
||||
let token = if request.token.as_ref() == SnapshotLeaseToken::revoke_all().as_bytes() {
|
||||
SnapshotLeaseToken::revoke_all()
|
||||
} else {
|
||||
SnapshotLeaseToken::from_slice(&request.token).map_err(|_| Status::invalid_argument("invalid lease token"))?
|
||||
};
|
||||
let token =
|
||||
SnapshotLeaseToken::from_slice(&request.token).map_err(|_| Status::invalid_argument("invalid lease token"))?;
|
||||
let Some(disk) = self.find_disk(&request.disk).await else {
|
||||
return Ok(Response::new(SnapshotLeaseMutationResponse {
|
||||
success: false,
|
||||
@@ -1518,11 +1494,11 @@ mod tests {
|
||||
use super::{
|
||||
compat_response_json, decode_msgpack_or_json, decode_rename_data_request_file_info,
|
||||
encode_batch_read_version_response_payloads, encode_file_info_msgpack, encode_msgpack, encode_msgpack_named,
|
||||
encode_read_multiple_response_payloads, encode_rename_data_response_payloads,
|
||||
encode_read_multiple_response_payloads, encode_rename_data_response_payloads, snapshot_lease_disabled_response,
|
||||
};
|
||||
use crate::storage::storage_api::ReadMultipleResp;
|
||||
use crate::storage::storage_api::RenameDataResp;
|
||||
use crate::storage::storage_api::rpc_consumer::node_service::BatchReadVersionResp;
|
||||
use crate::storage::storage_api::{DiskError, RenameDataResp};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -1533,6 +1509,17 @@ mod tests {
|
||||
count: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_lease_acquire_and_renew_fail_closed() {
|
||||
let response = snapshot_lease_disabled_response();
|
||||
let expected_error = DiskError::UnsupportedDisk.into();
|
||||
|
||||
assert!(!response.success);
|
||||
assert!(response.token.is_empty());
|
||||
assert_eq!(response.protocol_version, 1);
|
||||
assert_eq!(response.error, Some(expected_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_msgpack_or_json_prefers_binary_payload() {
|
||||
let payload = SamplePayload {
|
||||
|
||||
@@ -430,7 +430,7 @@ pub(crate) mod ecstore_config {
|
||||
pub(crate) mod ecstore_data_usage {
|
||||
pub(crate) use rustfs_ecstore::api::data_usage::{
|
||||
apply_bucket_usage_memory_overlay, init_compression_total_memory_from_backend, load_admin_data_usage_from_backend_cached,
|
||||
load_data_usage_from_backend, quota_object_size, record_bucket_delete_marker_memory, record_bucket_object_delete_memory,
|
||||
load_data_usage_from_backend, record_bucket_delete_marker_memory, record_bucket_object_delete_memory,
|
||||
record_bucket_object_version_write_memory, record_bucket_object_write_memory,
|
||||
record_bucket_object_write_unknown_previous_memory, store_compression_total_in_backend,
|
||||
};
|
||||
@@ -487,12 +487,8 @@ pub(crate) mod ecstore_metrics {
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) mod ecstore_notification {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::notification::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub(crate) use rustfs_ecstore::api::notification::{
|
||||
CrossPoolFenceFleetProofToken, NotificationSys, acquire_cross_pool_fence_fleet_proof,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, new_global_notification_sys,
|
||||
start_remote_version_state_fleet_probe,
|
||||
NotificationSys, get_global_notification_sys, new_global_notification_sys, start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -550,11 +546,6 @@ pub(crate) mod ecstore_test_support {
|
||||
}
|
||||
|
||||
pub(crate) mod ecstore_set_disk {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::set_disk::test_util::{
|
||||
MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause,
|
||||
fail_next_quota_ledger_save_for_test,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::set_disk::{
|
||||
DEFAULT_READ_BUFFER_SIZE, file_info_quorum_hash, get_lock_acquire_timeout, is_valid_storage_class,
|
||||
};
|
||||
@@ -1139,9 +1130,7 @@ pub(crate) trait StorageDiskRpcExt {
|
||||
) -> DiskResult<()>;
|
||||
async fn read_metadata(&self, volume: &str, path: &str) -> DiskResult<bytes::Bytes>;
|
||||
async fn delete_paths(&self, volume: &str, paths: &[String]) -> DiskResult<()>;
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> DiskResult<SnapshotLeaseToken>;
|
||||
async fn release_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> DiskResult<()>;
|
||||
async fn renew_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> DiskResult<SnapshotLeaseToken>;
|
||||
async fn stat_volume(&self, volume: &str) -> DiskResult<VolumeInfo>;
|
||||
async fn list_volumes(&self) -> DiskResult<Vec<VolumeInfo>>;
|
||||
async fn make_volume(&self, volume: &str) -> DiskResult<()>;
|
||||
@@ -1269,18 +1258,10 @@ where
|
||||
ecstore_disk::DiskAPI::delete_paths(self, volume, paths).await
|
||||
}
|
||||
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> DiskResult<SnapshotLeaseToken> {
|
||||
ecstore_disk::DiskAPI::acquire_snapshot_lease(self, volume, path).await
|
||||
}
|
||||
|
||||
async fn release_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> DiskResult<()> {
|
||||
ecstore_disk::DiskAPI::release_snapshot_lease(self, volume, path, token).await
|
||||
}
|
||||
|
||||
async fn renew_snapshot_lease(&self, volume: &str, path: &str, token: SnapshotLeaseToken) -> DiskResult<SnapshotLeaseToken> {
|
||||
ecstore_disk::DiskAPI::renew_snapshot_lease(self, volume, path, token).await
|
||||
}
|
||||
|
||||
async fn stat_volume(&self, volume: &str) -> DiskResult<VolumeInfo> {
|
||||
ecstore_disk::DiskAPI::stat_volume(self, volume).await
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ cd "$(dirname "$0")/.."
|
||||
# Excludes crates/e2e_test/ — test infrastructure legitimately uses s3s
|
||||
# to verify S3 behavior and does not widen the production s3s surface.
|
||||
S3S_IMPORT_FILES_BASELINE=213
|
||||
S3_ERROR_LINES_BASELINE=1620
|
||||
S3_ERROR_LINES_BASELINE=1621
|
||||
S3S_PATH_PATTERN='(^|[^"[:alnum:]_])s3s::'
|
||||
E2E_TEST_GLOB='--glob=!crates/e2e_test/**'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user