feat(ecstore): seed relaxed durability for new buckets (#5971)

This commit is contained in:
houseme
2026-08-12 12:54:20 +08:00
committed by GitHub
parent b00b7ab8f1
commit 6850482247
8 changed files with 304 additions and 102 deletions
+60
View File
@@ -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);
});
}
}
}
+46
View File
@@ -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");
+77 -1
View File
@@ -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
+1 -1
View File
@@ -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]
+12 -15
View File
@@ -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(),
}
}