mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-25 13:36:50 +00:00
feat(ecstore): seed relaxed durability for new buckets (#5971)
This commit is contained in:
@@ -64,10 +64,41 @@ impl BucketDurabilityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default durability tier seeded into a newly created bucket's metadata
|
||||
/// (rustfs/backlog#1811). `relaxed` aligns new buckets with MinIO's default
|
||||
/// posture: object data is still fdatasynced, while xl.meta and directory-entry
|
||||
/// fsyncs follow the relaxed durability gate.
|
||||
pub const ENV_NEW_BUCKET_DURABILITY_MODE: &str = "RUSTFS_NEW_BUCKET_DURABILITY_MODE";
|
||||
pub const DEFAULT_NEW_BUCKET_DURABILITY_MODE: &str = BUCKET_DURABILITY_MODE_RELAXED;
|
||||
|
||||
/// The `durability.json` bytes to seed into a freshly created bucket's metadata.
|
||||
/// Empty means "no override" (the bucket then follows the global
|
||||
/// `RUSTFS_DURABILITY_MODE`); otherwise the serialized chosen tier. Operators
|
||||
/// can set `inherit` to disable the new-bucket override. Invalid values also
|
||||
/// fail closed to inherit the global mode instead of seeding a surprising tier.
|
||||
pub fn new_bucket_durability_config_json() -> Vec<u8> {
|
||||
let raw = std::env::var(ENV_NEW_BUCKET_DURABILITY_MODE).unwrap_or_else(|_| DEFAULT_NEW_BUCKET_DURABILITY_MODE.to_string());
|
||||
let mode = raw.trim();
|
||||
if mode.eq_ignore_ascii_case("inherit") || mode.is_empty() || !BucketDurabilityConfig::is_valid_mode(mode) {
|
||||
return Vec::new();
|
||||
}
|
||||
serde_json::to_vec(&BucketDurabilityConfig::new(mode)).expect("BucketDurabilityConfig serialization cannot fail")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn new_bucket_seeded_mode() -> Option<String> {
|
||||
let json = new_bucket_durability_config_json();
|
||||
if json.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice::<BucketDurabilityConfig>(&json)
|
||||
.expect("new-bucket durability config must serialize")
|
||||
.normalized_mode()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_modes_are_recognized() {
|
||||
assert!(BucketDurabilityConfig::is_valid_mode("strict"));
|
||||
@@ -99,4 +130,33 @@ mod tests {
|
||||
let empty: BucketDurabilityConfig = serde_json::from_slice(b"{}").expect("deserialize empty");
|
||||
assert_eq!(empty.normalized_mode(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_bucket_default_seeds_relaxed_when_unset() {
|
||||
temp_env::with_var_unset(ENV_NEW_BUCKET_DURABILITY_MODE, || {
|
||||
assert_eq!(new_bucket_seeded_mode().as_deref(), Some(BUCKET_DURABILITY_MODE_RELAXED));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_bucket_default_honors_explicit_tiers() {
|
||||
for mode in [
|
||||
BUCKET_DURABILITY_MODE_STRICT,
|
||||
BUCKET_DURABILITY_MODE_RELAXED,
|
||||
BUCKET_DURABILITY_MODE_NONE,
|
||||
] {
|
||||
temp_env::with_var(ENV_NEW_BUCKET_DURABILITY_MODE, Some(mode), || {
|
||||
assert_eq!(new_bucket_seeded_mode().as_deref(), Some(mode));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_bucket_default_can_inherit_global_mode() {
|
||||
for mode in ["inherit", "", "bogus"] {
|
||||
temp_env::with_var(ENV_NEW_BUCKET_DURABILITY_MODE, Some(mode), || {
|
||||
assert_eq!(new_bucket_seeded_mode(), None);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,15 @@ impl BucketMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for a physically new user bucket. Existing or fabricated legacy
|
||||
/// metadata must use [`Self::new`] so upgrades do not rewrite their
|
||||
/// durability posture.
|
||||
pub fn new_with_default_durability(name: &str) -> Self {
|
||||
let mut metadata = Self::new(name);
|
||||
metadata.durability_config_json = super::durability::new_bucket_durability_config_json();
|
||||
metadata
|
||||
}
|
||||
|
||||
pub fn save_file_path(&self) -> String {
|
||||
format!("{}/{}/{}", BUCKET_META_PREFIX, self.name.as_str(), BUCKET_METADATA_FILE)
|
||||
}
|
||||
@@ -1378,6 +1387,43 @@ mod test {
|
||||
assert_ne!(old.bucket_incarnation_id, new.bucket_incarnation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_bucket_metadata_constructor_does_not_seed_durability() {
|
||||
temp_env::with_var_unset(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, || {
|
||||
let metadata = BucketMetadata::new("legacy-or-fabricated");
|
||||
assert!(metadata.durability_config_json.is_empty());
|
||||
assert!(metadata.durability_config().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_bucket_metadata_constructor_seeds_default_durability() {
|
||||
temp_env::with_var_unset(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, || {
|
||||
let metadata = BucketMetadata::new_with_default_durability("new-user-bucket");
|
||||
assert_eq!(
|
||||
metadata.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
|
||||
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
|
||||
);
|
||||
|
||||
let encoded = metadata.marshal_msg().expect("marshal metadata");
|
||||
let decoded = BucketMetadata::unmarshal(&encoded).expect("unmarshal metadata");
|
||||
assert_eq!(decoded.durability_config_json, metadata.durability_config_json);
|
||||
assert_eq!(
|
||||
decoded.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
|
||||
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_bucket_metadata_constructor_can_inherit_global_durability() {
|
||||
temp_env::with_var(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, Some("inherit"), || {
|
||||
let metadata = BucketMetadata::new_with_default_durability("strict-fleet-new-bucket");
|
||||
assert!(metadata.durability_config_json.is_empty());
|
||||
assert!(metadata.durability_config().is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_replication_config_updates_cannot_replace_bucket_incarnation() {
|
||||
let mut metadata = BucketMetadata::new("site-replication-update");
|
||||
|
||||
@@ -457,7 +457,13 @@ impl ECStore {
|
||||
None
|
||||
};
|
||||
|
||||
let mut meta = existing_metadata.unwrap_or_else(|| BucketMetadata::new(bucket));
|
||||
let mut meta = existing_metadata.unwrap_or_else(|| {
|
||||
if confirmed_missing && !is_meta_bucketname(bucket) {
|
||||
BucketMetadata::new_with_default_durability(bucket)
|
||||
} else {
|
||||
BucketMetadata::new(bucket)
|
||||
}
|
||||
});
|
||||
let existing_incarnation_is_authoritative = meta.bucket_incarnation_sidecar;
|
||||
if confirmed_missing || is_meta_bucketname(bucket) {
|
||||
meta.set_created(opts.created_at);
|
||||
@@ -1557,6 +1563,76 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn make_bucket_seeds_new_bucket_durability_override() {
|
||||
temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, None::<&str>)], async {
|
||||
let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-default-durability-{}", Uuid::new_v4().simple());
|
||||
|
||||
ecstore
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("new bucket should be created");
|
||||
|
||||
let metadata = metadata_sys::get_in(&ecstore.ctx, &bucket)
|
||||
.await
|
||||
.expect("metadata should load for the new bucket");
|
||||
assert_eq!(
|
||||
metadata.durability_config().and_then(|cfg| cfg.normalized_mode()).as_deref(),
|
||||
Some(crate::bucket::durability::BUCKET_DURABILITY_MODE_RELAXED)
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn force_create_existing_bucket_keeps_durability_override() {
|
||||
let (_disk_paths, ecstore) = setup_bucket_delete_test_env().await;
|
||||
let bucket = format!("bucket-force-durability-{}", Uuid::new_v4().simple());
|
||||
|
||||
temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, Some("inherit"))], async {
|
||||
ecstore
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("plain bucket should be created without a durability override");
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
metadata_sys::get_in(&ecstore.ctx, &bucket)
|
||||
.await
|
||||
.expect("metadata should load after initial create")
|
||||
.durability_config()
|
||||
.is_none(),
|
||||
"test setup: the existing bucket must start without an override"
|
||||
);
|
||||
|
||||
temp_env::async_with_vars([(crate::bucket::durability::ENV_NEW_BUCKET_DURABILITY_MODE, None::<&str>)], async {
|
||||
ecstore
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
force_create: true,
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("force create should update existing bucket metadata");
|
||||
})
|
||||
.await;
|
||||
|
||||
let metadata = metadata_sys::get_in(&ecstore.ctx, &bucket)
|
||||
.await
|
||||
.expect("metadata should load after force create");
|
||||
assert!(metadata.lock_enabled, "force create sanity check: Object Lock should be enabled");
|
||||
assert!(
|
||||
metadata.durability_config().is_none(),
|
||||
"force create must not apply the new-bucket default to existing bucket metadata"
|
||||
);
|
||||
}
|
||||
|
||||
/// `DeleteBucket`'s emptiness check is a raw disk scan (`has_xlmeta_files`),
|
||||
/// not an S3-level listing, so "the client drained the bucket" and "the
|
||||
/// bucket is deletable" are two different contracts. Nothing pinned the
|
||||
|
||||
@@ -57,7 +57,7 @@ thiserror.workspace = true
|
||||
parking_lot.workspace = true
|
||||
rand.workspace = true
|
||||
smallvec = { workspace = true, features = ["serde"] }
|
||||
smartstring.workspace = true
|
||||
compact_str.workspace = true
|
||||
crossbeam-queue = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::fast_lock::guard::FastLockGuard;
|
||||
use compact_str::CompactString;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use smartstring::SmartString;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
@@ -143,15 +143,15 @@ impl ObjectKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimized object key using smart strings for better performance
|
||||
/// Optimized object key using compact strings for better performance
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OptimizedObjectKey {
|
||||
/// Bucket name - uses inline storage for small strings
|
||||
pub bucket: SmartString<smartstring::LazyCompact>,
|
||||
pub bucket: CompactString,
|
||||
/// Object name - uses inline storage for small strings
|
||||
pub object: SmartString<smartstring::LazyCompact>,
|
||||
pub object: CompactString,
|
||||
/// Version - optional for latest version semantics
|
||||
pub version: Option<SmartString<smartstring::LazyCompact>>,
|
||||
pub version: Option<CompactString>,
|
||||
/// Cached hash to avoid recomputation
|
||||
hash_cache: OnceLock<u64>,
|
||||
}
|
||||
@@ -189,10 +189,7 @@ impl Ord for OptimizedObjectKey {
|
||||
}
|
||||
|
||||
impl OptimizedObjectKey {
|
||||
pub fn new(
|
||||
bucket: impl Into<SmartString<smartstring::LazyCompact>>,
|
||||
object: impl Into<SmartString<smartstring::LazyCompact>>,
|
||||
) -> Self {
|
||||
pub fn new(bucket: impl Into<CompactString>, object: impl Into<CompactString>) -> Self {
|
||||
Self {
|
||||
bucket: bucket.into(),
|
||||
object: object.into(),
|
||||
@@ -202,9 +199,9 @@ impl OptimizedObjectKey {
|
||||
}
|
||||
|
||||
pub fn with_version(
|
||||
bucket: impl Into<SmartString<smartstring::LazyCompact>>,
|
||||
object: impl Into<SmartString<smartstring::LazyCompact>>,
|
||||
version: impl Into<SmartString<smartstring::LazyCompact>>,
|
||||
bucket: impl Into<CompactString>,
|
||||
object: impl Into<CompactString>,
|
||||
version: impl Into<CompactString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bucket: bucket.into(),
|
||||
@@ -232,9 +229,9 @@ impl OptimizedObjectKey {
|
||||
/// Convert from regular ObjectKey
|
||||
pub fn from_object_key(key: &ObjectKey) -> Self {
|
||||
Self {
|
||||
bucket: SmartString::from(key.bucket.as_ref()),
|
||||
object: SmartString::from(key.object.as_ref()),
|
||||
version: key.version.as_ref().map(|v| SmartString::from(v.as_ref())),
|
||||
bucket: CompactString::from(key.bucket.as_ref()),
|
||||
object: CompactString::from(key.object.as_ref()),
|
||||
version: key.version.as_ref().map(|v| CompactString::from(v.as_ref())),
|
||||
hash_cache: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user