Compare commits

..

6 Commits

Author SHA1 Message Date
Zhengchao An 436a1be899 ci: allow macOS release builds to finish (#6976) 2026-09-01 05:01:23 +08:00
houseme 1ea1dfa0a1 fix(put): honor bucket default SSE in path selection (#6970)
Co-authored-by: heihutu <heihutu@gmail.com>
2026-09-01 03:43:39 +08:00
Zhengchao An c45a8c35c4 test(ecstore): cover disk metric sequence snapshot (#6872) 2026-09-01 03:43:26 +08:00
唐小鸭 4932d1dedf fix(admin): allow re-pairing non-empty sites in site replication add (#6961)
The add preflight unconditionally rejected any topology with data on
more than one site, which made `replicate remove` a one-way door: a DR
cluster whose sites both hold data could never be re-paired, and the
only way out was wiping one side by hand.

Admit a multi-non-empty add when every bucket held by more than one
requested site is provably safe to merge through the existing
backfill/resync convergence: versioning must be Enabled on every holder
(so a same-key object from the peer lands as another version instead of
replacing the only copy) and object-lock enablement must match (lock
cannot be toggled after bucket creation). Incompatible adds are still
rejected, now with the operator recovery steps (empty one side, re-run
replicate add, resync) instead of a bare refusal. Bucket configs that
fail to decode fail the preflight closed.

A committed add now also clears this site's own half-finished
pending_remove, mirroring the join receiver (rustfs/rustfs#5963);
otherwise the reconcile tick would replay the stale removal against the
freshly re-paired peer and dismantle the new pairing.

Refs rustfs/backlog#2070
2026-09-01 01:21:06 +08:00
cxymds 041af14143 perf(s3): bound snowball member imports (#6945)
* fix(s3): harden Snowball extract error boundaries

* fix(s3): close Snowball extract compatibility gaps

* fix(s3): verify Snowball request body completion

* test(s3): reject forged Snowball streaming signatures

* build(deps): pin Snowball archive parser limits

* fix(s3): preserve Snowball trailer and member errors

* docs(architecture): register Snowball tar fork cleanup

* refactor(s3): route Snowball errors through object boundary

* ci(deps): allow pinned tokio-tar source

* fix: align Snowball archive codec detection

* fix(s3): harden Snowball codec compatibility

* fix(s3): preserve Snowball codec compatibility

* test(zip): align yield wake assertion with Tokio

* fix(rio): preserve legacy large-block reads

* fix(zip): accept blank tar numeric fields

* fix(s3): align Snowball member import semantics

* fix(s3): authorize PAX legal-hold conditions

* refactor(s3): preserve Snowball error boundary

* fix(iam): support legal-hold policy conditions

* perf(s3): bound Snowball member imports

* fix(s3): preserve bounded Snowball import contracts

* fix(s3): preserve bounded import invariants

* fix(s3): route Snowball limits through app boundary

* refactor(s3): keep bounded import errors behind facade

* fix(s3): satisfy Snowball feature lint gates

* fix(s3): preserve Snowball safety guardrails
2026-08-31 16:45:32 +00:00
houseme e44007012b fix(scanner): recover legacy usage floor from backup (#6964)
* fix(scanner): recover legacy usage floor from backup

Allow scanner usage-floor startup and leadership fencing to use a valid legacy backup when the legacy primary read fails with a corruption-shaped error.

Keep v2 primary read failures, stale metadata, transient I/O, and missing or invalid backups fail-closed.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(scanner): cover legacy backup fencing gaps (#6966)

* fix(scanner): recover legacy usage from valid backup

* fix(scanner): recover legacy usage floor from backup

Allow scanner usage-floor startup and leadership fencing to use a valid legacy backup when the legacy primary read fails with a corruption-shaped error.

Keep v2 primary read failures, stale metadata, transient I/O, and missing or invalid backups fail-closed.

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(scanner): cover legacy backup fencing gaps

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: Henry Guo <marshawcoco@gmail.com>
2026-09-01 00:01:38 +08:00
16 changed files with 2526 additions and 310 deletions
+1 -1
View File
@@ -244,7 +244,7 @@ jobs:
needs: [ build-check, prepare-platform-matrix ]
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
runs-on: ${{ matrix.os }}
timeout-minutes: 150
timeout-minutes: 180
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Release binaries ship without dial9 telemetry and therefore do not need
+19
View File
@@ -2435,6 +2435,25 @@ mod tests {
assert_eq!(window.acc_time, 18_000);
}
#[test]
fn timed_action_slot_snapshot_skips_writer_owned_slot() {
let slot = TimedActionSlot::default();
slot.unix_sec.store(70, Ordering::Relaxed);
slot.count.store(2, Ordering::Relaxed);
slot.acc_time.store(18_000, Ordering::Relaxed);
slot.version.store(2, Ordering::Release);
assert_eq!(slot.snapshot(), Some((70, 2, 18_000)));
assert_eq!(slot.version.compare_exchange(2, 3, Ordering::AcqRel, Ordering::Relaxed), Ok(2));
slot.unix_sec.store(71, Ordering::Relaxed);
slot.count.store(1, Ordering::Relaxed);
slot.acc_time.store(11_000, Ordering::Relaxed);
assert_eq!(slot.snapshot(), None);
slot.version.store(4, Ordering::Release);
assert_eq!(slot.snapshot(), Some((71, 1, 11_000)));
}
#[test]
fn disk_health_metrics_snapshot_exports_waiting_errors_and_operation_windows() {
let metrics = DiskHealthMetricEpoch::default();
+33
View File
@@ -131,6 +131,39 @@ pub(crate) async fn read_config_with_revision<S: ScannerObjectIO>(
}
}
pub(crate) fn usage_floor_primary_read_error_allows_backup(err: &Error) -> bool {
match err {
Error::FileCorrupt
| Error::CorruptedFormat
| Error::CorruptedBackend
| Error::PartMissingOrCorrupt
| Error::LessData
| Error::MoreData => true,
Error::Io(io_error) => {
matches!(io_error.kind(), std::io::ErrorKind::InvalidData | std::io::ErrorKind::UnexpectedEof)
|| error_chain_has_usage_floor_corruption_signature(io_error)
}
_ => false,
}
}
fn error_chain_has_usage_floor_corruption_signature(error: &(dyn std::error::Error + 'static)) -> bool {
let mut current = Some(error);
while let Some(err) = current {
let message = err.to_string();
if message.contains("InlineData value out of range")
|| message.contains("InlineData key out of range")
|| message.contains("insufficient data for metadata")
|| message.contains("insufficient data for meta length")
|| message.contains("insufficient data for CRC")
{
return true;
}
current = err.source();
}
false
}
/// Read only the object revision without materializing its body.
pub(crate) async fn read_config_revision<S: ScannerObjectIO>(store: Arc<S>, path: &str) -> StorageResult<DataUsageCacheRevision> {
match store
+18 -1
View File
@@ -14,7 +14,9 @@
/// Scanner cycle-state codec, persisted usage floors, and cycle-state persistence.
use super::*;
use crate::ScannerGetObjectReader;
use crate::data_usage_define::{DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_RECOVERY_PATH};
use crate::data_usage_define::{
DATA_USAGE_BLOOM_RECOVERY_PATH, DATA_USAGE_RECOVERY_PATH, usage_floor_primary_read_error_allows_backup,
};
use crate::storage_api::owner::ObjectIO as _;
use tokio::io::AsyncReadExt as _;
@@ -1717,6 +1719,7 @@ pub(super) async fn persisted_usage_floor_for_startup(
let backup_path = format!("{primary_path}.bkp");
let is_v2_path = primary_path == DATA_USAGE_OBJ_NAME_PATH.as_str();
let mut recovered_primary_companion_epoch = None;
let mut primary_read_error = None;
let primary_epoch = match read_config_with_revision(storeapi.clone(), primary_path).await {
Ok((Some(data), revision)) => {
let usage = serde_json::from_slice::<DataUsageInfo>(&data).map_err(|err| {
@@ -1776,6 +1779,12 @@ pub(super) async fn persisted_usage_floor_for_startup(
}
}
Ok((None, _)) => None,
Err(err) if !is_v2_path && usage_floor_primary_read_error_allows_backup(&err) => {
primary_read_error = Some(format!("failed to read scanner usage epoch floor from {primary_path}: {err}"));
invalid_baseline_path.get_or_insert_with(|| primary_path.to_string());
unrecoverable_baseline_path.get_or_insert_with(|| primary_path.to_string());
None
}
Err(err) => {
return Err(ScannerError::Other(format!(
"failed to read scanner usage epoch floor from {primary_path}: {err}"
@@ -1855,6 +1864,14 @@ pub(super) async fn persisted_usage_floor_for_startup(
)));
}
}
if let Some(primary_read_error) = primary_read_error
&& !any_found
{
return Err(ScannerError::Other(format!(
"{}; no valid scanner usage floor backup was available at {backup_path}",
primary_read_error
)));
}
if any_found {
if bootstrap_pending {
return Err(ScannerError::Other(
+29 -8
View File
@@ -13,6 +13,7 @@
// limitations under the License.
/// Leader-lock claiming, usage-epoch fencing, and lock-loss handling.
use super::*;
use crate::data_usage_define::usage_floor_primary_read_error_allows_backup;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ScannerLeadershipClaimReconcile {
@@ -142,20 +143,34 @@ pub(super) async fn usage_snapshot_for_epoch_fence(
}
}
for path in [
LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()),
] {
let (legacy, _) = read_config_with_revision(storeapi.clone(), &path)
.await
.map_err(|err| ScannerError::Other(format!("failed to read legacy scanner usage epoch fence: {err}")))?;
let legacy_primary_path = LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str().to_string();
let legacy_backup_path = format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str());
let mut legacy_primary_read_error = None;
for path in [&legacy_primary_path, &legacy_backup_path] {
let legacy = match read_config_with_revision(storeapi.clone(), path).await {
Ok((legacy, _)) => legacy,
Err(err) if path == &legacy_primary_path && usage_floor_primary_read_error_allows_backup(&err) => {
legacy_primary_read_error = Some(format!("failed to read legacy scanner usage epoch fence from {path}: {err}"));
continue;
}
Err(err) => {
return Err(ScannerError::Other(format!(
"failed to read legacy scanner usage epoch fence from {path}: {err}"
)));
}
};
if let Some(legacy) = legacy.as_deref() {
let usage = decode_usage_snapshot_for_epoch_fence(legacy, &path, false)?;
let usage = decode_usage_snapshot_for_epoch_fence(legacy, path, false)?;
if invalid_primary_epoch.is_none_or(|epoch| usage.scanner_epoch.unwrap_or_default() >= epoch) {
return Ok(Some(usage));
}
}
}
if let Some(legacy_primary_read_error) = legacy_primary_read_error {
return Err(ScannerError::Other(format!(
"{legacy_primary_read_error}; no valid legacy scanner usage epoch fence backup was available at {legacy_backup_path}"
)));
}
// A missing usage snapshot is an uninitialized state, not an empty
// snapshot. Leadership fencing may proceed without creating a plausible
// default; the first authoritative scanner publication will create it.
@@ -258,6 +273,12 @@ pub(super) async fn fence_scanner_usage_epoch_with_expected_epoch(
Some(epoch) if epoch == claimed_epoch => return Ok(()),
Some(_) | None => {}
}
// A validated pre-marker legacy baseline needs an explicit complete
// identity before acquiring an epoch. Otherwise the v2 reader would
// reject the fenced value on its next startup.
if !usage.usage_snapshot_bootstrap_pending {
usage.usage_snapshot_complete = true;
}
usage.scanner_epoch = Some(claimed_epoch);
let data = serde_json::to_vec(&usage)
.map_err(|err| ScannerError::Other(format!("failed to encode scanner usage epoch fence: {err}")))?;
+314
View File
@@ -574,6 +574,7 @@ struct MemoryConfigStore {
objects: Mutex<HashMap<String, Vec<u8>>>,
revisions: Mutex<HashMap<String, u64>>,
insert_after_gets: Mutex<HashMap<String, Vec<u8>>>,
read_errors: Mutex<HashMap<String, EcstoreError>>,
delayed_gets: Mutex<HashMap<String, Duration>>,
non_regular_objects: Mutex<HashSet<String>>,
fail_put_number: Mutex<HashMap<String, usize>>,
@@ -623,6 +624,9 @@ impl crate::storage_api::scanner_io::ObjectIO for MemoryConfigStore {
_opts: &ObjectOptions,
) -> EcstoreResult<GetObjectReader> {
let key = memory_config_key(bucket, object);
if let Some(error) = self.read_errors.lock().await.get(&key).cloned() {
return Err(error);
}
if let Some(delay) = self.delayed_gets.lock().await.remove(&key) {
tokio::time::sleep(delay).await;
}
@@ -2902,6 +2906,147 @@ async fn scanner_usage_floor_recovers_from_incomplete_v2_primary_using_fenced_ba
);
}
async fn seed_legacy_primary_read_error_with_backup(store: &Arc<MemoryConfigStore>, error: EcstoreError, epoch: u64, cycle: u64) {
let legacy_primary = LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str();
let legacy_backup = format!("{legacy_primary}.bkp");
let mut backup = complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0);
backup.scanner_epoch = Some(epoch);
backup.scanner_cycle = Some(cycle);
store
.read_errors
.lock()
.await
.insert(memory_config_key(RUSTFS_META_BUCKET, legacy_primary), error);
store.objects.lock().await.insert(
memory_config_key(RUSTFS_META_BUCKET, &legacy_backup),
serde_json::to_vec(&backup).expect("legacy backup usage snapshot should encode"),
);
}
#[tokio::test]
async fn scanner_usage_floor_recovers_legacy_backup_after_primary_decode_error() {
let store = Arc::new(MemoryConfigStore::default());
seed_legacy_primary_read_error_with_backup(&store, EcstoreError::other("InlineData value out of range"), 19, 41).await;
let (floor, state) = persisted_usage_floor_for_startup(store.clone(), true)
.await
.expect("valid legacy backup should recover the startup floor");
assert_eq!(state, PersistedUsageFloorStartup::Authoritative);
assert_eq!(
floor,
PersistedUsageFloor {
next_cycle: 42,
leader_epoch: 19,
}
);
assert_eq!(
persisted_usage_floor(store)
.await
.expect("valid legacy backup should recover the authoritative floor"),
floor
);
}
#[tokio::test]
async fn scanner_usage_floor_does_not_bootstrap_over_corrupt_legacy_primary_without_backup() {
let store = Arc::new(MemoryConfigStore::default());
let legacy_primary = LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str();
store
.read_errors
.lock()
.await
.insert(memory_config_key(RUSTFS_META_BUCKET, legacy_primary), EcstoreError::FileCorrupt);
let err = persisted_usage_floor_for_startup(store, true)
.await
.expect_err("corrupt legacy primary without a valid backup must remain fail-closed");
assert!(err.to_string().contains("no valid scanner usage floor backup"), "unexpected error: {err}");
}
#[tokio::test]
async fn scanner_usage_floor_does_not_fallback_to_legacy_after_corrupt_v2_primary() {
let store = Arc::new(MemoryConfigStore::default());
let mut v2_backup = complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0);
v2_backup.scanner_epoch = Some(8);
v2_backup.scanner_cycle = Some(11);
let mut legacy = complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0);
legacy.scanner_epoch = Some(3);
legacy.scanner_cycle = Some(7);
store.read_errors.lock().await.insert(
memory_config_key(RUSTFS_META_BUCKET, DATA_USAGE_OBJ_NAME_PATH.as_str()),
EcstoreError::FileCorrupt,
);
store.objects.lock().await.insert(
memory_config_key(RUSTFS_META_BUCKET, &format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str())),
serde_json::to_vec(&v2_backup).expect("v2 backup usage snapshot should encode"),
);
store.objects.lock().await.insert(
memory_config_key(RUSTFS_META_BUCKET, LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()),
serde_json::to_vec(&legacy).expect("legacy usage snapshot should encode"),
);
let err = persisted_usage_floor_for_startup(store, true)
.await
.expect_err("corrupt v2 primary must not recover without a primary revision");
assert!(
err.to_string().contains(&format!(
"failed to read scanner usage epoch floor from {}",
DATA_USAGE_OBJ_NAME_PATH.as_str()
)),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn scanner_usage_floor_keeps_transient_primary_read_error_fail_closed() {
let store = Arc::new(MemoryConfigStore::default());
let legacy_primary = LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str();
seed_legacy_primary_read_error_with_backup(
&store,
EcstoreError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"connection reset while reading usage primary",
)),
19,
41,
)
.await;
let err = persisted_usage_floor_for_startup(store, true)
.await
.expect_err("transient primary errors must not be converted into backup recovery");
assert!(
err.to_string()
.contains(&format!("failed to read scanner usage epoch floor from {legacy_primary}")),
"unexpected error: {err}"
);
assert!(
!err.to_string().contains("no valid scanner usage floor backup"),
"transient error should not enter corrupt-primary fallback: {err}"
);
}
#[tokio::test]
async fn scanner_usage_floor_keeps_outdated_primary_metadata_fail_closed() {
let store = Arc::new(MemoryConfigStore::default());
let legacy_primary = LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str();
seed_legacy_primary_read_error_with_backup(&store, EcstoreError::OutdatedXLMeta, 19, 41).await;
let err = persisted_usage_floor_for_startup(store, true)
.await
.expect_err("outdated primary metadata must not be converted into backup recovery");
assert!(
err.to_string()
.contains(&format!("failed to read scanner usage epoch floor from {legacy_primary}")),
"unexpected error: {err}"
);
assert!(
!err.to_string().contains("no valid scanner usage floor backup"),
"outdated metadata should not enter corrupt-primary fallback: {err}"
);
}
#[tokio::test]
async fn scanner_usage_floor_does_not_bootstrap_over_incomplete_v2_primary() {
let store = Arc::new(MemoryConfigStore::default());
@@ -3004,6 +3149,19 @@ async fn scanner_leadership_fencing_recovers_incomplete_v2_primary_from_backup()
assert_eq!(recovered.scanner_cycle, Some(103));
}
#[tokio::test]
async fn scanner_usage_floor_leadership_fencing_recovers_legacy_backup_after_primary_decode_error() {
let store = Arc::new(MemoryConfigStore::default());
seed_legacy_primary_read_error_with_backup(&store, EcstoreError::other("InlineData value out of range"), 19, 41).await;
let recovered = usage_snapshot_for_epoch_fence(store, None, false)
.await
.expect("a valid legacy backup should provide the fencing baseline")
.expect("the fencing baseline should be present");
assert_eq!(recovered.scanner_epoch, Some(19));
assert_eq!(recovered.scanner_cycle, Some(41));
}
#[tokio::test]
async fn scanner_usage_floor_ignores_older_backup_after_primary_epoch_fence() {
let store = Arc::new(MemoryConfigStore::default());
@@ -3955,6 +4113,162 @@ async fn scanner_defers_leadership_when_usage_snapshots_are_stably_absent() {
assert!(read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str()).await.is_err());
}
#[tokio::test]
async fn scanner_usage_floor_leadership_claim_recovers_legacy_backup_after_primary_decode_error() {
let store = Arc::new(MemoryConfigStore::default());
let ctx = CancellationToken::new();
seed_legacy_primary_read_error_with_backup(&store, EcstoreError::other("InlineData value out of range"), 19, 41).await;
let mut revision = DataUsageCacheRevision::Missing;
let mut cycle = CurrentCycle::default();
let mut persisted_epoch = 19;
assert!(
claim_scanner_leadership(
&ctx,
store.clone(),
&mut cycle,
&mut revision,
&mut persisted_epoch,
false,
ScannerCycleResetPolicy::None,
)
.await
);
let state = read_config(store.clone(), DATA_USAGE_BLOOM_NAME_PATH.as_str())
.await
.expect("leadership claim should persist after legacy backup recovery");
let (_, claimed_epoch) = decode_scanner_cycle_state(&state).expect("leadership claim should decode");
assert_eq!(claimed_epoch, 20);
assert_eq!(persisted_epoch, 20);
let usage = read_config(store, DATA_USAGE_OBJ_NAME_PATH.as_str())
.await
.expect("legacy backup recovery should publish a fenced v2 usage primary");
let usage = serde_json::from_slice::<DataUsageInfo>(&usage).expect("fenced v2 usage primary should decode");
assert_eq!(usage.scanner_epoch, Some(20));
assert_eq!(usage.scanner_cycle, Some(41));
}
#[tokio::test]
#[serial_test::serial]
async fn scanner_legacy_usage_backup_survives_fencing_and_restart_after_real_metadata_truncation() {
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
let (temp_dir, store) = setup_scanner_cycle_store_with_usage_baseline(false).await;
let mut usage = complete_usage_with_bucket_count(Some(std::time::SystemTime::UNIX_EPOCH), 0);
usage.usage_snapshot_complete = false;
usage.scanner_cycle = Some(41);
let mut data = serde_json::to_vec(&usage).expect("legacy usage should encode");
data.resize(data.len() + 16 * 1024, b' ');
let legacy_path = LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str();
let backup_path = format!("{legacy_path}.bkp");
for path in [legacy_path, backup_path.as_str()] {
save_config(store.clone(), path, data.clone())
.await
.expect("legacy usage fixture should persist");
}
let mut truncated_files = Vec::new();
for disk_index in 0..4 {
let path = temp_dir
.path()
.join(format!("pool0/disk{disk_index}"))
.join(RUSTFS_META_BUCKET)
.join(legacy_path)
.join("xl.meta");
let file = tokio::fs::OpenOptions::new()
.write(true)
.open(&path)
.await
.expect("legacy inline metadata should exist");
assert!(file.metadata().await.expect("metadata should be readable").len() > 4096);
file.set_len(4096).await.expect("fixture should truncate at a page boundary");
truncated_files.push((
path.clone(),
tokio::fs::read(&path)
.await
.expect("truncated evidence should remain readable"),
));
}
let store = restart_scanner_cycle_store_from(&store).await;
let error = read_config_with_revision(store.clone(), legacy_path)
.await
.expect_err("truncated primary must fail in the real object reader");
assert!(
error.to_string().contains("InlineData value out of range"),
"unexpected truncated-primary error: {error}"
);
assert_eq!(
read_config_with_revision(store.clone(), &backup_path)
.await
.expect("backup should remain readable")
.0,
Some(data.clone()),
);
let (floor, state) = persisted_usage_floor_for_startup(store.clone(), true)
.await
.expect("intact legacy backup must recover startup despite truncated primary");
assert_eq!(
floor,
PersistedUsageFloor {
next_cycle: 42,
leader_epoch: 0
}
);
assert_eq!(state, PersistedUsageFloorStartup::Authoritative);
let baseline = read_data_usage_persist_baseline(store.clone())
.await
.expect("publication must also read the intact backup");
assert_eq!(baseline.data.as_deref(), Some(data.as_slice()));
assert_eq!(baseline.revision, DataUsageCacheRevision::Missing);
fence_scanner_usage_epoch_with_expected_epoch(&CancellationToken::new(), store.clone(), 7, None, false)
.await
.expect("legacy backup must be fenced into v2");
let fenced = read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
.await
.expect("fencing must publish a v2 usage primary");
let fenced = serde_json::from_slice::<DataUsageInfo>(&fenced).expect("fenced v2 usage primary should decode");
assert!(
fenced.usage_snapshot_complete,
"the fenced pre-marker baseline must become a complete v2 identity"
);
let store = restart_scanner_cycle_store_from(&store).await;
let (floor, state) = persisted_usage_floor_for_startup(store.clone(), true)
.await
.expect("a restart after fencing must preserve the recovered floor");
assert_eq!(
floor,
PersistedUsageFloor {
next_cycle: 42,
leader_epoch: 7
}
);
assert_eq!(state, PersistedUsageFloorStartup::Authoritative);
let restarted = restart_scanner_cycle_store_from(&store).await;
assert_eq!(
persisted_usage_floor(restarted)
.await
.expect("fenced floor must survive another restart"),
PersistedUsageFloor {
next_cycle: 42,
leader_epoch: 7
}
);
for (path, bytes) in truncated_files {
assert_eq!(tokio::fs::read(path).await.expect("legacy evidence must not be removed"), bytes);
}
assert_eq!(
read_config(store, &backup_path)
.await
.expect("legacy backup must remain intact"),
data
);
global_metrics().set_cycle(None).await;
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
}
#[tokio::test]
async fn usage_bootstrap_pending_unblocks_first_leadership_claim() {
let store = Arc::new(MemoryConfigStore::default());
+27 -2
View File
@@ -13,6 +13,7 @@
// limitations under the License.
/// Data-usage snapshot persistence: CAS store pipeline, epoch baselines, and observed-snapshot cleanup.
use super::*;
use crate::data_usage_define::usage_floor_primary_read_error_allows_backup;
use crate::storage_api::owner::ScannerPublicationCommitState;
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
@@ -53,6 +54,30 @@ pub(super) struct DataUsagePersistBaseline {
pub(super) revision: DataUsageCacheRevision,
}
async fn read_usage_persist_candidate(
storeapi: Arc<impl ScannerObjectIO>,
path: &str,
) -> Result<(Option<Vec<u8>>, DataUsageCacheRevision), EcstoreError> {
let primary = read_config_with_revision(storeapi.clone(), path).await;
if path != LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str() {
return primary;
}
let Err(primary_error) = &primary else {
return primary;
};
if !usage_floor_primary_read_error_allows_backup(primary_error) {
return primary;
}
let backup_path = format!("{path}.bkp");
let backup = read_config_with_revision(storeapi, &backup_path).await?;
if backup.0.as_deref().is_some_and(|data| {
serde_json::from_slice::<DataUsageInfo>(data).is_ok_and(|usage| data_usage_info_has_persisted_baseline_identity(&usage))
}) {
return Ok(backup);
}
primary
}
/// Read the bytes used as the baseline for a usage publication while keeping
/// the v2 primary revision as the CAS fence. During an interrupted upgrade the
/// primary can be valid JSON without a baseline identity; in that case a
@@ -68,7 +93,7 @@ pub(super) async fn read_data_usage_persist_baseline(
LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()),
] {
let (candidate, _) = read_config_with_revision(storeapi.clone(), &path).await?;
let (candidate, _) = read_usage_persist_candidate(storeapi.clone(), &path).await?;
let Some(candidate) = candidate else {
continue;
};
@@ -107,7 +132,7 @@ pub(super) async fn read_data_usage_persist_baseline(
LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str().to_string(),
format!("{}.bkp", LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str()),
] {
let (candidate, _) = read_config_with_revision(storeapi.clone(), &path).await?;
let (candidate, _) = read_usage_persist_candidate(storeapi.clone(), &path).await?;
let Some(candidate) = candidate else {
continue;
};
+401 -56
View File
@@ -32,7 +32,9 @@ use crate::admin::storage_api::bucket::replication::{
};
use crate::admin::storage_api::bucket::target::{BucketTarget, BucketTargetType, BucketTargets};
use crate::admin::storage_api::bucket::utils::{deserialize, serialize};
use crate::admin::storage_api::bucket::{AdminReplicationConfigExt as _, AdminVersioningConfigExt as _};
use crate::admin::storage_api::bucket::{
AdminObjectLockConfigExt as _, AdminReplicationConfigExt as _, AdminVersioningConfigExt as _,
};
use crate::admin::storage_api::contract::bucket::{
BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp,
};
@@ -61,18 +63,18 @@ use rustfs_madmin::{
BucketBandwidth, GroupStatus, IDPSettings, InProgressMetric, InQueueMetric, LDAPConfigSettings, LDAPSettings,
OpenIDProviderSettings, PeerInfo, PeerSite, QStat, ReplProxyMetric, ReplicateAddStatus, ReplicateEditStatus,
ReplicateRemoveStatus, ResyncBucketStatus, SITE_REPL_API_VERSION, SR_IAM_ITEM_STS_ACC, SR_IAM_ITEM_STS_ACC_LEGACY,
SRBucketMeta, SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMUser, SRILMExpiryStatsSummary, SRInfo,
SRMetric, SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping, SRPolicyStatsSummary,
SRRemoveReq, SRResyncOpStatus, SRSTSCredential, SRSessionPolicy, SRSiteSummary, SRStateEditReq, SRStateInfo, SRStatusInfo,
SRSvcAccChange, SRSvcAccCreate, SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat,
SRBucketInfo, SRBucketMeta, SRBucketStatsSummary, SRGroupInfo, SRGroupStatsSummary, SRIAMItem, SRIAMUser,
SRILMExpiryStatsSummary, SRInfo, SRMetric, SRMetricsSummary, SRPeerError, SRPeerJoinReq, SRPendingOperation, SRPolicyMapping,
SRPolicyStatsSummary, SRRemoveReq, SRResyncOpStatus, SRSTSCredential, SRSessionPolicy, SRSiteSummary, SRStateEditReq,
SRStateInfo, SRStatusInfo, SRSvcAccChange, SRSvcAccCreate, SRUserStatsSummary, SiteReplicationInfo, SyncStatus, WorkerStat,
};
use rustfs_policy::policy::{
Policy,
action::{Action, AdminAction},
};
use s3s::dto::{
DeleteMarkerReplicationStatus, DeleteReplicationStatus, ExistingObjectReplicationStatus, ReplicaModificationsStatus,
ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
DeleteMarkerReplicationStatus, DeleteReplicationStatus, ExistingObjectReplicationStatus, ObjectLockConfiguration,
ReplicaModificationsStatus, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus, VersioningConfiguration,
};
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use serde::Deserialize;
@@ -287,12 +289,21 @@ struct SiteReplicationAddPreflightInfo {
endpoint: String,
deployment_id: String,
enabled: bool,
bucket_count: usize,
bucket_names: HashSet<String>,
buckets: BTreeMap<String, AddPreflightBucketCompat>,
peer_deployment_ids: BTreeSet<String>,
idp_settings: serde_json::Value,
}
/// The per-bucket facts the add preflight compares across sites when more
/// than one requested site holds data (rustfs/backlog#2070). Only properties
/// that cannot converge after the add belong here — everything else is
/// reconciled by the bucket-metadata sync.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AddPreflightBucketCompat {
versioning_enabled: bool,
object_lock_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct SRPeerJoinResponse {
peer: PeerInfo,
@@ -935,19 +946,65 @@ fn idp_settings_value(settings: &IDPSettings) -> S3Result<serde_json::Value> {
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize IDP settings failed: {e}")))
}
/// The merge-critical facts of one bucket a site reported in its add
/// preflight metainfo. The configs arrive as the `build_sr_info` wire form
/// (base64-encoded XML); an undecodable config fails the preflight instead of
/// defaulting, so corruption cannot admit an unsafe merge.
fn add_preflight_bucket_compat(endpoint: &str, bucket: &str, info: &SRBucketInfo) -> S3Result<AddPreflightBucketCompat> {
let versioning_enabled = info
.versioning
.as_deref()
.map(|raw| {
deserialize::<VersioningConfiguration>(&decode_bucket_meta_wire_value(raw))
.map(|config| config.enabled())
.map_err(|e| {
s3_error!(
InvalidRequest,
"site `{endpoint}` reported an unreadable versioning config for bucket `{bucket}`: {e}"
)
})
})
.transpose()?
.unwrap_or(false);
let object_lock_enabled = info
.object_lock_config
.as_deref()
.map(|raw| {
deserialize::<ObjectLockConfiguration>(&decode_bucket_meta_wire_value(raw))
.map(|config| config.enabled())
.map_err(|e| {
s3_error!(
InvalidRequest,
"site `{endpoint}` reported an unreadable object-lock config for bucket `{bucket}`: {e}"
)
})
})
.transpose()?
.unwrap_or(false);
Ok(AddPreflightBucketCompat {
versioning_enabled,
object_lock_enabled,
})
}
fn add_preflight_info_from_sr_info(
site: &PeerSite,
info: SRInfo,
idp_settings: IDPSettings,
) -> S3Result<SiteReplicationAddPreflightInfo> {
let bucket_names = info.buckets.keys().cloned().collect();
let buckets = info
.buckets
.iter()
.map(|(bucket, bucket_info)| {
add_preflight_bucket_compat(&site.endpoint, bucket, bucket_info).map(|compat| (bucket.clone(), compat))
})
.collect::<S3Result<BTreeMap<_, _>>>()?;
Ok(SiteReplicationAddPreflightInfo {
name: if info.name.is_empty() { site.name.clone() } else { info.name },
endpoint: site.endpoint.clone(),
deployment_id: info.deployment_id,
enabled: info.enabled,
bucket_count: info.buckets.len(),
bucket_names,
buckets,
peer_deployment_ids: info.state.peers.keys().cloned().collect(),
idp_settings: idp_settings_value(&idp_settings)?,
})
@@ -1045,7 +1102,7 @@ fn validate_add_preflight_topology(infos: &[SiteReplicationAddPreflightInfo], lo
if info.deployment_id == local_peer.deployment_id {
local_seen = true;
}
if info.bucket_count > 0 {
if !info.buckets.is_empty() {
non_empty_sites.push(info.name.clone());
}
}
@@ -1070,11 +1127,15 @@ fn validate_add_preflight_topology(infos: &[SiteReplicationAddPreflightInfo], lo
}
if non_empty_sites.len() > 1 {
return Err(s3_error!(
InvalidRequest,
"site replication can be initialized with data on only one site; non-empty sites: {}",
non_empty_sites.join(", ")
));
validate_nonempty_add_bucket_compatibility(infos)?;
info!(
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
result = "nonempty_sites_admitted",
non_empty_sites = %non_empty_sites.join(", "),
"admin site replication state"
);
}
let requested: BTreeSet<String> = infos.iter().map(|info| info.deployment_id.clone()).collect();
@@ -1091,6 +1152,76 @@ fn validate_add_preflight_topology(infos: &[SiteReplicationAddPreflightInfo], lo
Ok(())
}
/// Operator recovery guidance for a rejected add between sites that both hold
/// data — the only supported path is to empty one side and let a resync copy
/// the objects back (rustfs/backlog#2070).
const NONEMPTY_ADD_RECOVERY_HINT: &str = "to pair these sites, delete the conflicting bucket (or its data) on all but one \
site, re-run `replicate add`, then run `replicate resync` from the surviving site to restore the objects";
/// Admission check for an add in which more than one requested site holds
/// data — the DR re-pair case: two sites that were unpaired (or never
/// finished a removal) both keep their buckets, and the historical
/// unconditional "only one site may hold data" rejection made `replicate
/// remove` a one-way door (rustfs/backlog#2070).
///
/// The add is admitted when every bucket name held by MORE than one requested
/// site is provably safe to merge through the existing backfill/resync
/// convergence:
///
/// - versioning must be Enabled on every holder: replication into a versioned
/// bucket lands as another version, so a same-key object from the peer
/// never destroys the local copy — while on an unversioned holder it would
/// silently replace the only copy;
/// - object-lock enablement must match across holders: lock cannot be toggled
/// after bucket creation, so a mismatch never converges, and replicating
/// locked objects into a lock-less bucket would strip their WORM guarantee.
///
/// A bucket held by a single site carries no merge risk — the post-add
/// backfill creates it on the peers exactly as the historical
/// one-non-empty-site path always has.
fn validate_nonempty_add_bucket_compatibility(infos: &[SiteReplicationAddPreflightInfo]) -> S3Result<()> {
let mut holders: BTreeMap<&str, Vec<(&SiteReplicationAddPreflightInfo, AddPreflightBucketCompat)>> = BTreeMap::new();
for info in infos {
for (bucket, compat) in &info.buckets {
holders.entry(bucket.as_str()).or_default().push((info, *compat));
}
}
for (bucket, holders) in holders {
let [(first, first_compat), rest @ ..] = holders.as_slice() else {
continue;
};
if rest.is_empty() {
continue;
}
if let Some((conflicting, _)) = rest
.iter()
.find(|(_, compat)| compat.object_lock_enabled != first_compat.object_lock_enabled)
{
let (enabled_on, disabled_on) = if first_compat.object_lock_enabled {
(&first.name, &conflicting.name)
} else {
(&conflicting.name, &first.name)
};
return Err(s3_error!(
InvalidRequest,
"bucket `{bucket}` has object lock enabled on site `{enabled_on}` but not on site `{disabled_on}`, and \
object lock cannot be changed after bucket creation; {NONEMPTY_ADD_RECOVERY_HINT}"
));
}
if let Some((unversioned, _)) = holders.iter().find(|(_, compat)| !compat.versioning_enabled) {
return Err(s3_error!(
InvalidRequest,
"bucket `{bucket}` exists on more than one site but does not have versioning enabled on site `{}`, so \
merging could silently overwrite objects; enable versioning on every site holding it, or {NONEMPTY_ADD_RECOVERY_HINT}",
unversioned.name
));
}
}
Ok(())
}
fn site_replication_bootstrap_token(uri: &Uri) -> Option<String> {
query_pairs(uri).get("bootstrapToken").cloned()
}
@@ -5701,6 +5832,53 @@ fn sts_replication_compatibility_policy<'a>(claims: &HashMap<String, Value>, par
(!claims.contains_key(OIDC_VIRTUAL_PARENT_CLAIM) && !parent_policy_mapping.is_empty()).then_some(parent_policy_mapping)
}
/// Adopt only the fields a committed add computed onto the freshly loaded
/// transaction state. Everything else is owned by writers that commit without
/// touching `updated_at` (retry events, peer-edit generations, resync
/// progress, the acks/clears of an already pending rotation), so the add's
/// `updated_at` CAS cannot vouch for them — they keep the freshly loaded
/// value, except `pending_remove`:
///
/// A committed add supersedes a half-finished removal THIS site started,
/// exactly as an accepted join does on the receiving side (`apply_peer_join`,
/// rustfs/rustfs#5963): the adopted topology IS the new membership, while the
/// pending record only exists to keep notifying peers about the old one. Left
/// in place, the reconcile tick would replay the stale `SRRemoveReq` against
/// a freshly re-paired peer — `SRPeerRemoveHandler` applies it
/// unconditionally — and dismantle the pairing this add just created
/// (rustfs/backlog#2070). A removal that started AFTER the add's preflight
/// snapshot moved `updated_at`, so the CAS refuses the commit before this
/// runs.
///
/// The exhaustive destructure makes adding a state field a compile error here
/// until it is classified.
fn adopt_add_commit_state(state: &mut SiteReplicationState, next_state: SiteReplicationState) {
let SiteReplicationState {
name,
service_account_access_key,
service_account_secret_key: _,
service_account_parent,
peers,
updated_at,
resync_status: _,
pending_rotation: _,
pending_remove: _,
pending_endpoint_refresh: _,
retry_queue: _,
iam_deletion_replays: _,
sync_state_initialized,
edit_generation: _,
applied_edit_generations: _,
} = next_state;
state.name = name;
state.service_account_access_key = service_account_access_key;
state.service_account_parent = service_account_parent;
state.peers = peers;
state.updated_at = updated_at;
state.sync_state_initialized = sync_state_initialized;
state.pending_remove = None;
}
pub struct SiteReplicationAddHandler {}
/// MinIO's `SRPeerJoin` replies with an empty body on success; synthesize the
@@ -5756,7 +5934,7 @@ impl Operation for SiteReplicationAddHandler {
let bootstrap_buckets = preflight_infos
.iter()
.filter(|info| !same_identity_endpoint(&info.endpoint, &local_peer.endpoint))
.flat_map(|info| info.bucket_names.iter().cloned())
.flat_map(|info| info.buckets.keys().cloned())
.collect();
let add_in_progress_guard = SiteReplicationAddInProgressGuard::start(lifecycle_guard, bootstrap_buckets)?;
let mut state = merge_add_sites(
@@ -5853,36 +6031,7 @@ impl Operation for SiteReplicationAddHandler {
"site replication state changed during peer join; the peers may already be joined — re-run replicate add"
));
}
// Adopt only the fields this add computed. Everything else is
// owned by writers that commit without touching `updated_at`
// (retry events, peer-edit generations, resync progress, the
// acks/clears of an already pending rotation or removal), so the
// CAS above cannot vouch for them — they keep the freshly loaded
// value. The exhaustive destructure makes adding a state field a
// compile error here until it is classified.
let SiteReplicationState {
name,
service_account_access_key,
service_account_secret_key: _,
service_account_parent,
peers,
updated_at,
resync_status: _,
pending_rotation: _,
pending_remove: _,
pending_endpoint_refresh: _,
retry_queue: _,
iam_deletion_replays: _,
sync_state_initialized,
edit_generation: _,
applied_edit_generations: _,
} = next_state;
state.name = name;
state.service_account_access_key = service_account_access_key;
state.service_account_parent = service_account_parent;
state.peers = peers;
state.updated_at = updated_at;
state.sync_state_initialized = sync_state_initialized;
adopt_add_commit_state(state, next_state);
let edit_generation = next_peer_edit_generation(state);
Ok((state.clone(), edit_generation))
})
@@ -9421,18 +9570,29 @@ mod tests {
}
fn preflight_site(name: &str, endpoint: &str, deployment_id: &str, bucket_count: usize) -> SiteReplicationAddPreflightInfo {
// Site-prefixed names keep the generated buckets disjoint across
// sites; tests exercising shared-bucket merges insert their own.
let buckets = (0..bucket_count)
.map(|i| (format!("{name}-bucket-{i}"), versioned_bucket()))
.collect();
SiteReplicationAddPreflightInfo {
name: name.to_string(),
endpoint: endpoint.to_string(),
deployment_id: deployment_id.to_string(),
enabled: false,
bucket_count,
bucket_names: HashSet::new(),
buckets,
peer_deployment_ids: BTreeSet::new(),
idp_settings: serde_json::json!({"provider": "same"}),
}
}
fn versioned_bucket() -> AddPreflightBucketCompat {
AddPreflightBucketCompat {
versioning_enabled: true,
object_lock_enabled: false,
}
}
#[test]
fn test_validate_add_preflight_topology_accepts_matching_sites() {
let local_peer = PeerInfo {
@@ -9491,20 +9651,120 @@ mod tests {
assert!(err.to_string().contains("IDP settings mismatch"));
}
// rustfs/backlog#2070: two sites that both hold data (the DR re-pair
// case) must be admitted when their bucket sets are merge-safe, instead
// of the historical unconditional "only one site may hold data" rejection
// that made `replicate remove` a one-way door.
#[test]
fn test_validate_add_preflight_topology_rejects_multiple_non_empty_sites() {
fn test_validate_add_preflight_topology_accepts_compatible_non_empty_sites() {
let local_peer = PeerInfo {
deployment_id: "local-dep".to_string(),
..peer("local", "https://local.example.com")
};
let mut local = preflight_site("local", "https://local.example.com", "local-dep", 1);
let mut remote = preflight_site("remote", "https://remote.example.com", "remote-dep", 1);
// The same bucket on both sites, versioning enabled on both: the
// exact shape a formerly paired cluster is left in after a remove.
local.buckets.insert("shared".to_string(), versioned_bucket());
remote.buckets.insert("shared".to_string(), versioned_bucket());
let infos = vec![local, remote];
validate_add_preflight_topology(&infos, &local_peer).expect("compatible non-empty sites should be admitted");
}
#[test]
fn test_validate_add_preflight_topology_accepts_disjoint_non_empty_sites() {
let local_peer = PeerInfo {
deployment_id: "local-dep".to_string(),
..peer("local", "https://local.example.com")
};
let infos = vec![
preflight_site("local", "https://local.example.com", "local-dep", 1),
preflight_site("remote", "https://remote.example.com", "remote-dep", 1),
preflight_site("local", "https://local.example.com", "local-dep", 2),
preflight_site("remote", "https://remote.example.com", "remote-dep", 2),
];
let err = validate_add_preflight_topology(&infos, &local_peer).expect_err("multiple non-empty sites should fail");
validate_add_preflight_topology(&infos, &local_peer).expect("disjoint non-empty sites should be admitted");
}
assert!(err.to_string().contains("only one site"));
#[test]
fn test_validate_add_preflight_topology_rejects_shared_bucket_object_lock_mismatch() {
let local_peer = PeerInfo {
deployment_id: "local-dep".to_string(),
..peer("local", "https://local.example.com")
};
let mut local = preflight_site("local", "https://local.example.com", "local-dep", 0);
let mut remote = preflight_site("remote", "https://remote.example.com", "remote-dep", 0);
local.buckets.insert(
"shared".to_string(),
AddPreflightBucketCompat {
versioning_enabled: true,
object_lock_enabled: true,
},
);
remote.buckets.insert("shared".to_string(), versioned_bucket());
let infos = vec![local, remote];
let err = validate_add_preflight_topology(&infos, &local_peer).expect_err("object-lock mismatch should fail");
let message = err.to_string();
assert!(
message.contains("bucket `shared` has object lock enabled on site `local`"),
"got: {message}"
);
// The rejection must carry the operator recovery steps, not a bare no.
assert!(message.contains("re-run `replicate add`"), "got: {message}");
assert!(message.contains("`replicate resync`"), "got: {message}");
}
#[test]
fn test_validate_add_preflight_topology_rejects_shared_unversioned_bucket() {
let local_peer = PeerInfo {
deployment_id: "local-dep".to_string(),
..peer("local", "https://local.example.com")
};
let mut local = preflight_site("local", "https://local.example.com", "local-dep", 0);
let mut remote = preflight_site("remote", "https://remote.example.com", "remote-dep", 0);
local.buckets.insert("shared".to_string(), versioned_bucket());
remote.buckets.insert(
"shared".to_string(),
AddPreflightBucketCompat {
versioning_enabled: false,
object_lock_enabled: false,
},
);
let infos = vec![local, remote];
let err = validate_add_preflight_topology(&infos, &local_peer).expect_err("shared unversioned bucket should fail");
let message = err.to_string();
assert!(
message.contains("bucket `shared`") && message.contains("versioning enabled on site `remote`"),
"got: {message}"
);
assert!(message.contains("re-run `replicate add`"), "got: {message}");
}
// A bucket held by a single site never blocks the add, whatever its
// configs: the backfill creates it on the peers exactly like the
// historical one-non-empty-site path.
#[test]
fn test_validate_add_preflight_topology_ignores_unshared_bucket_configs() {
let local_peer = PeerInfo {
deployment_id: "local-dep".to_string(),
..peer("local", "https://local.example.com")
};
let mut local = preflight_site("local", "https://local.example.com", "local-dep", 1);
let remote = preflight_site("remote", "https://remote.example.com", "remote-dep", 1);
local.buckets.insert(
"local-only".to_string(),
AddPreflightBucketCompat {
versioning_enabled: false,
object_lock_enabled: true,
},
);
let infos = vec![local, remote];
validate_add_preflight_topology(&infos, &local_peer).expect("unshared buckets should not block the add");
}
#[test]
@@ -9524,6 +9784,91 @@ mod tests {
assert!(err.to_string().contains("different site replication peer set"));
}
// add_preflight_bucket_compat reads the build_sr_info wire form:
// base64-encoded XML for both the versioning and the object-lock config.
#[test]
fn test_add_preflight_bucket_compat_parses_wire_configs() {
let info = SRBucketInfo {
versioning: Some(
BASE64_STANDARD.encode_to_string(b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"),
),
object_lock_config: Some(BASE64_STANDARD.encode_to_string(
b"<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled></ObjectLockConfiguration>",
)),
..Default::default()
};
let compat = add_preflight_bucket_compat("https://a.example.com", "b", &info).expect("wire configs should parse");
assert!(compat.versioning_enabled);
assert!(compat.object_lock_enabled);
}
#[test]
fn test_add_preflight_bucket_compat_absent_and_suspended_configs_are_disabled() {
let absent = add_preflight_bucket_compat("https://a.example.com", "b", &SRBucketInfo::default())
.expect("absent configs should parse");
assert!(!absent.versioning_enabled);
assert!(!absent.object_lock_enabled);
let suspended = SRBucketInfo {
versioning: Some(
BASE64_STANDARD
.encode_to_string(b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>"),
),
..Default::default()
};
let compat =
add_preflight_bucket_compat("https://a.example.com", "b", &suspended).expect("suspended config should parse");
assert!(!compat.versioning_enabled, "suspended versioning is not merge-safe");
}
// rustfs/backlog#2070: a committed add must supersede this site's own
// half-finished removal (mirroring the join side, rustfs/rustfs#5963) —
// otherwise the reconcile tick replays the stale removal against the
// freshly re-paired peer and dismantles the new pairing.
#[test]
fn test_adopt_add_commit_state_clears_pending_remove() {
let mut state = SiteReplicationState {
pending_remove: Some(PendingRemove {
id: "remove-1".to_string(),
..Default::default()
}),
edit_generation: 7,
..Default::default()
};
let next_state = SiteReplicationState {
name: "local".to_string(),
peers: BTreeMap::from([
("local-dep".to_string(), peer("local", "https://local.example.com")),
("remote-dep".to_string(), peer("remote", "https://remote.example.com")),
]),
updated_at: Some(OffsetDateTime::now_utc()),
sync_state_initialized: true,
..Default::default()
};
adopt_add_commit_state(&mut state, next_state);
assert!(state.pending_remove.is_none(), "the committed add supersedes the removal");
assert_eq!(state.peers.len(), 2, "the add's topology is adopted");
assert_eq!(state.edit_generation, 7, "commit-owned fields keep the loaded value");
}
// Fail closed: a config this site cannot read must fail the preflight
// instead of defaulting into an unsafe admission.
#[test]
fn test_add_preflight_bucket_compat_rejects_undecodable_config() {
let info = SRBucketInfo {
versioning: Some(BASE64_STANDARD.encode_to_string(b"<VersioningConfiguration")),
..Default::default()
};
let err = add_preflight_bucket_compat("https://a.example.com", "b", &info).expect_err("broken XML should fail");
assert!(err.to_string().contains("unreadable versioning config"));
}
/// P1-15 review follow-up: the receiving side of the ordering fence. Two
/// nodes of the sending site can fan out in the opposite order to their
/// commits; the receiver decides ordering from the generation the sender
+15 -3
View File
@@ -20,8 +20,8 @@ use time::OffsetDateTime;
mod ecstore_bucket {
pub(crate) use crate::storage::storage_api::ecstore_bucket::{
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, quota, replication, target, utils,
versioning, versioning_sys,
bandwidth, bucket_target_sys, durability, lifecycle, metadata, metadata_sys, object_lock, quota, replication, target,
utils, versioning, versioning_sys,
};
}
@@ -185,6 +185,16 @@ impl AdminVersioningConfigExt for s3s::dto::VersioningConfiguration {
}
}
pub(crate) trait AdminObjectLockConfigExt {
fn enabled(&self) -> bool;
}
impl AdminObjectLockConfigExt for s3s::dto::ObjectLockConfiguration {
fn enabled(&self) -> bool {
<s3s::dto::ObjectLockConfiguration as ecstore_bucket::object_lock::ObjectLockApi>::enabled(self)
}
}
pub(crate) mod bandwidth {
pub(crate) mod monitor {
pub(crate) type BandwidthDetails = super::super::ecstore_bucket::bandwidth::monitor::BandwidthDetails;
@@ -863,7 +873,9 @@ pub(crate) mod bucket {
pub(crate) use super::replication;
pub(crate) use super::target;
pub(crate) use super::versioning_sys;
pub(crate) use super::{AdminReplicationConfigExt, AdminVersioningConfigExt, is_reserved_or_invalid_bucket};
pub(crate) use super::{
AdminObjectLockConfigExt, AdminReplicationConfigExt, AdminVersioningConfigExt, is_reserved_or_invalid_bucket,
};
pub(crate) mod utils {
pub(crate) use super::super::ecstore_utils::{deserialize, is_valid_object_prefix, serialize};
File diff suppressed because it is too large Load Diff
+64 -39
View File
@@ -308,14 +308,14 @@ pub(crate) fn guard_put_object_body_read_timeout(
})
}
struct PooledBufferReader {
pub(super) struct PooledBufferReader {
buffer: PooledBuffer,
len: usize,
pos: usize,
}
impl PooledBufferReader {
fn new(buffer: PooledBuffer, len: usize) -> Self {
pub(super) fn new(buffer: PooledBuffer, len: usize) -> Self {
Self { buffer, len, pos: 0 }
}
}
@@ -631,7 +631,7 @@ fn select_put_path_with_concurrency(
/// where the allocation cost is negligible (≤4KiB memcpy).
const POOL_BYPASS_MAX_SIZE: usize = 4 * 1024;
async fn read_small_put_body_into<R, B>(body: &mut R, buf: &mut B, size: usize) -> S3Result<()>
pub(super) async fn read_small_put_body_into<R, B>(body: &mut R, buf: &mut B, size: usize) -> S3Result<()>
where
R: AsyncRead + Unpin,
B: bytes::BufMut,
@@ -1084,8 +1084,6 @@ impl DefaultObjectUsecase {
let ingress_stage_start = put_stage_metrics_enabled.then(Instant::now);
let should_compress =
is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough;
let server_side_encryption_requested =
server_side_encryption.is_some() || sse_customer_algorithm.is_some() || ssekms_key_id.is_some();
// Resolve the store through the request-bound server context
// (backlog#1052 S6), not the process-global handle, so an embedded
@@ -1133,38 +1131,6 @@ impl DefaultObjectUsecase {
base_buffer_size
};
// Detect zero-copy opportunity before encryption/compression decisions
// Zero-copy is beneficial for large unencrypted, uncompressed objects
let enable_zero_copy = should_use_zero_copy(size, &req.headers);
if enable_zero_copy {
// Record zero-copy write attempt
counter!("rustfs_zero_copy_write_attempts_total").increment(1);
histogram!("rustfs_zero_copy_write_size_bytes").record(size as f64);
debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key);
}
let (put_path, zero_copy_eager_put_path_status, use_zero_copy_eager_put_path, use_empty_or_small_eager_put_path) =
select_put_path_with_concurrency(
size,
&req.headers,
server_side_encryption_requested,
should_compress,
false,
concurrent_put_requests,
);
if use_zero_copy_eager_put_path {
counter!(buffered_write::ATTEMPTS_TOTAL).increment(1);
histogram!(buffered_write::ATTEMPT_SIZE_BYTES).record(size as f64);
}
rustfs_io_metrics::record_put_object_diagnostics(
put_path,
zero_copy_eager_put_path_status,
size,
buffer_size,
use_large_put_concurrency_tuning,
);
let sse_config_stage_start = put_stage_metrics_enabled.then(Instant::now);
let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok();
rustfs_io_metrics::record_put_object_stage_duration_from("app_sse_config_lookup", sse_config_stage_start);
@@ -1203,6 +1169,32 @@ impl DefaultObjectUsecase {
effective_kms_key_id = None;
}
let server_side_encryption_requested =
effective_sse.is_some() || sse_customer_algorithm.is_some() || effective_kms_key_id.is_some();
let (put_path, zero_copy_eager_put_path_status, use_zero_copy_eager_put_path, use_empty_or_small_eager_put_path) =
select_put_path_with_concurrency(
size,
&req.headers,
server_side_encryption_requested,
should_compress,
false,
concurrent_put_requests,
);
if use_zero_copy_eager_put_path {
counter!("rustfs_zero_copy_write_attempts_total").increment(1);
histogram!("rustfs_zero_copy_write_size_bytes").record(size as f64);
debug!("Zero-copy write enabled for {} byte object (bucket={}, key={})", size, bucket, key);
counter!(buffered_write::ATTEMPTS_TOTAL).increment(1);
histogram!(buffered_write::ATTEMPT_SIZE_BYTES).record(size as f64);
}
rustfs_io_metrics::record_put_object_diagnostics(
put_path,
zero_copy_eager_put_path_status,
size,
buffer_size,
use_large_put_concurrency_tuning,
);
// Validate SSE-C headers early: reject partial/invalid combinations per S3 spec
validate_sse_headers_for_write(
effective_sse.as_ref(),
@@ -1739,7 +1731,7 @@ impl DefaultObjectUsecase {
rustfs_io_metrics::record_put_object(
duration_ms,
size,
enable_zero_copy, // Track if zero-copy was enabled
use_zero_copy_eager_put_path, // Track if zero-copy was enabled
);
}
@@ -1776,7 +1768,10 @@ mod tests {
use super::*;
use futures::StreamExt;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use s3s::dto::{DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule};
use s3s::dto::{
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRule, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
@@ -2392,6 +2387,36 @@ mod tests {
assert!(!use_small_eager);
}
#[test]
fn select_put_path_treats_bucket_default_sse_as_encrypted() {
for (algorithm, kms_key_id) in [
(ServerSideEncryption::AES256, None),
(ServerSideEncryption::AWS_KMS, Some("bucket-key")),
] {
let config = ServerSideEncryptionConfiguration {
rules: vec![ServerSideEncryptionRule {
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
sse_algorithm: ServerSideEncryption::from_static(algorithm),
kms_master_key_id: kms_key_id.map(|id| SSEKMSKeyId::from(id.to_string())),
}),
bucket_key_enabled: None,
}],
};
let (effective_sse, effective_kms_key_id) = resolve_bucket_default_sse(Some(&config), None, None, false);
let encryption_requested = effective_sse.is_some() || effective_kms_key_id.is_some();
for size in [512 * 1024, 2 * 1024 * 1024] {
let (path, status, use_zero_copy, use_small_eager) =
select_put_path_with_concurrency(size, &HeaderMap::new(), encryption_requested, false, false, 256);
assert_eq!(path, "streaming");
assert_eq!(status, PUT_EAGER_STATUS_ENCRYPTED);
assert!(!use_zero_copy);
assert!(!use_small_eager);
}
}
}
#[test]
fn should_use_small_eager_put_path_allows_a_b_override_at_1mb() {
let headers = HeaderMap::new();
+4 -1
View File
@@ -978,9 +978,12 @@ pub(crate) mod bucket {
}
pub(crate) mod concurrency {
#[cfg(test)]
pub(crate) use crate::storage::storage_api::concurrency_consumer::SNOWBALL_MEMBER_COMMIT_LIMIT;
pub(crate) use crate::storage::storage_api::concurrency_consumer::{
ConcurrencyManager, DiskReadAdmission, ForegroundWriteAdmission, GetObjectGuard, IoQueueStatus, IoStrategy,
PutObjectGuard, get_concurrency_aware_buffer_size, get_concurrency_manager, get_put_concurrency_aware_buffer_size,
PutObjectGuard, SNOWBALL_STAGING_BYTES_LIMIT, get_concurrency_aware_buffer_size, get_concurrency_manager,
get_put_concurrency_aware_buffer_size,
};
}
+171 -2
View File
@@ -31,10 +31,12 @@ use rustfs_io_metrics::bandwidth::{BandwidthMonitor, BandwidthSnapshot};
use rustfs_io_metrics::{MetricsCollector, PerformanceMetrics};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use tokio::sync::Semaphore;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tracing::debug;
const DERIVED_LARGE_PUT_ADMISSION_LIMIT_MAX: usize = 32;
pub(crate) const SNOWBALL_MEMBER_COMMIT_LIMIT: usize = 32;
pub(crate) const SNOWBALL_STAGING_BYTES_LIMIT: usize = 4 * MI_B;
/// Global concurrency manager instance
pub(crate) static CONCURRENCY_MANAGER: LazyLock<ConcurrencyManager> = LazyLock::new(ConcurrencyManager::new);
@@ -69,6 +71,12 @@ pub struct ConcurrencyManager {
metrics_collector: Arc<MetricsCollector>,
/// Foreground write admission policy, resolved once at startup.
foreground_write_admission_policy: ForegroundWriteAdmissionPolicy,
/// Snowball members are internal PUTs, so they use a separate global gate
/// from preparation through the independently owned post-commit tail.
snowball_member_commit_semaphore: Arc<Semaphore>,
/// Bounds the owned member bodies and metadata retained between TAR parsing
/// and storage commit across all extract requests.
snowball_staging_bytes_semaphore: Arc<Semaphore>,
}
impl std::fmt::Debug for ConcurrencyManager {
@@ -417,6 +425,8 @@ impl ConcurrencyManager {
bandwidth_monitor,
metrics_collector,
foreground_write_admission_policy,
snowball_member_commit_semaphore: Arc::new(Semaphore::new(SNOWBALL_MEMBER_COMMIT_LIMIT)),
snowball_staging_bytes_semaphore: Arc::new(Semaphore::new(SNOWBALL_STAGING_BYTES_LIMIT)),
}
}
@@ -556,6 +566,40 @@ impl ConcurrencyManager {
.await
}
/// Admit a Snowball member through the foreground PUT policy using the
/// member's logical size. The outer archive has a separate preflight, while
/// every member shares the ordinary PUT gate and its wait/rejection policy.
pub(crate) async fn admit_snowball_foreground_write(
&self,
member_size: i64,
) -> Result<ForegroundWriteAdmission, tokio::sync::AcquireError> {
self.foreground_write_admission_policy
.admit(ForegroundWriteAdmissionKind::PutObject, member_size)
.await
}
/// Acquire one global Snowball member lifecycle slot.
pub(crate) async fn acquire_snowball_member_commit(&self) -> Result<OwnedSemaphorePermit, tokio::sync::AcquireError> {
self.snowball_member_commit_semaphore.clone().acquire_owned().await
}
/// Try to acquire one global Snowball member lifecycle slot.
pub(crate) fn try_acquire_snowball_member_commit(&self) -> Option<OwnedSemaphorePermit> {
self.snowball_member_commit_semaphore.clone().try_acquire_owned().ok()
}
/// Try to reserve prepared-member bytes without waiting.
///
/// A producer holding a non-empty micro-batch must use this method and
/// flush before waiting, otherwise several archives can each retain part of
/// the global budget while waiting forever for the remainder.
pub(crate) fn try_acquire_snowball_staging_bytes(&self, bytes: u32) -> Option<OwnedSemaphorePermit> {
self.snowball_staging_bytes_semaphore
.clone()
.try_acquire_many_owned(bytes)
.ok()
}
/// Admit a multipart UploadPart request under the configured write gate.
///
/// Multipart workloads can saturate memory and internode write streams with
@@ -1050,13 +1094,138 @@ impl Default for ConcurrencyManager {
mod integration_tests {
use super::super::io_schedule::{IoLoadLevel, IoPriority};
use super::super::request_guard::GetObjectGuard;
use super::{ConcurrencyManager, ForegroundWriteAdmission, derive_large_put_admission_limit};
use super::{
ConcurrencyManager, ForegroundWriteAdmission, SNOWBALL_MEMBER_COMMIT_LIMIT, SNOWBALL_STAGING_BYTES_LIMIT,
derive_large_put_admission_limit,
};
use crate::storage::storage_api::concurrency_consumer::PutObjectGuard;
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionSnapshotProvider, WorkloadClass};
use rustfs_io_core::io_profile::{AccessPattern, StorageMedia};
use serial_test::serial;
use std::time::Duration;
#[test]
fn test_snowball_gates_are_global_bounded_and_reusable() {
let manager = ConcurrencyManager::new();
let clone = manager.clone();
let commit_permits = manager
.snowball_member_commit_semaphore
.clone()
.try_acquire_many_owned(u32::try_from(SNOWBALL_MEMBER_COMMIT_LIMIT).expect("Snowball commit limit must fit into u32"))
.expect("the exact Snowball commit limit must be available");
assert!(
clone.snowball_member_commit_semaphore.clone().try_acquire_owned().is_err(),
"a cloned manager must share the global commit gate"
);
drop(commit_permits);
assert!(clone.snowball_member_commit_semaphore.clone().try_acquire_owned().is_ok());
let staging_bytes = u32::try_from(SNOWBALL_STAGING_BYTES_LIMIT).expect("Snowball staging limit must fit into u32");
let staging_permit = manager
.try_acquire_snowball_staging_bytes(staging_bytes)
.expect("the exact Snowball staging budget must be available");
assert!(
clone.try_acquire_snowball_staging_bytes(1).is_none(),
"a cloned manager must share the global staging budget"
);
drop(staging_permit);
assert!(clone.try_acquire_snowball_staging_bytes(1).is_some());
}
#[tokio::test]
async fn test_snowball_members_share_the_strict_foreground_put_gate() {
let manager = ConcurrencyManager::with_put_admission_for_test(true, 2, Duration::ZERO);
let outer = match manager
.admit_put_object(1)
.await
.expect("strict outer admission must remain open")
{
ForegroundWriteAdmission::Admitted(permit) => permit,
outcome => panic!("strict outer admission must return a permit: {outcome:?}"),
};
let member = match manager
.admit_snowball_foreground_write(1)
.await
.expect("strict member admission must remain open")
{
ForegroundWriteAdmission::Admitted(permit) => permit,
outcome => panic!("strict member admission must return a permit: {outcome:?}"),
};
assert!(
matches!(
manager
.admit_snowball_foreground_write(1)
.await
.expect("strict member admission must remain open"),
ForegroundWriteAdmission::Rejected
),
"a saturated Snowball member admission must preserve the zero-wait rejection policy"
);
assert!(
matches!(
manager.admit_put_object(1).await.expect("strict gate must remain usable"),
ForegroundWriteAdmission::Rejected
),
"outer PUTs and Snowball members must exhaust the same strict gate"
);
drop(outer);
let replacement = match manager
.admit_snowball_foreground_write(1)
.await
.expect("released strict capacity must be reusable")
{
ForegroundWriteAdmission::Admitted(permit) => permit,
outcome => panic!("strict replacement admission must return a permit: {outcome:?}"),
};
drop((member, replacement));
}
#[tokio::test]
async fn test_snowball_members_use_their_size_for_the_large_foreground_put_gate() {
let min_size = 16 * 1024 * 1024;
let manager = ConcurrencyManager::with_large_put_admission_for_test(true, 1, min_size, Duration::ZERO);
assert!(matches!(
manager
.admit_put_object((min_size - 1) as i64)
.await
.expect("small outer archive admission must remain open"),
ForegroundWriteAdmission::Disabled
));
let large_member = match manager
.admit_snowball_foreground_write(min_size as i64)
.await
.expect("large Snowball member admission must remain open")
{
ForegroundWriteAdmission::Admitted(permit) => permit,
outcome => panic!("large Snowball member must consume the large PUT gate: {outcome:?}"),
};
assert!(matches!(
manager
.admit_snowball_foreground_write(min_size as i64)
.await
.expect("saturated Snowball member admission must remain open"),
ForegroundWriteAdmission::Rejected
));
assert!(matches!(
manager
.admit_put_object(min_size as i64)
.await
.expect("ordinary large PUT admission must remain open"),
ForegroundWriteAdmission::Rejected
));
assert!(matches!(
manager
.admit_snowball_foreground_write((min_size - 1) as i64)
.await
.expect("small Snowball member admission must remain open"),
ForegroundWriteAdmission::Disabled
));
drop(large_member);
}
#[tokio::test]
#[serial]
async fn test_concurrency_manager_priority_queue_integration() {
+3
View File
@@ -51,6 +51,9 @@ pub use io_schedule::{
pub use request_guard::{GetObjectGuard, PutObjectGuard};
// Concurrency manager
#[cfg(test)]
pub(crate) use manager::SNOWBALL_MEMBER_COMMIT_LIMIT;
pub(crate) use manager::SNOWBALL_STAGING_BYTES_LIMIT;
pub use manager::{ConcurrencyManager, DiskReadAdmission, ForegroundWriteAdmission};
// ============================================
+4 -1
View File
@@ -125,9 +125,12 @@ pub(crate) mod access_consumer {
}
pub(crate) mod concurrency_consumer {
#[cfg(test)]
pub(crate) use super::super::concurrency::SNOWBALL_MEMBER_COMMIT_LIMIT;
pub(crate) use super::super::concurrency::{
ConcurrencyManager, DiskReadAdmission, ForegroundWriteAdmission, GetObjectGuard, IoQueueStatus, IoStrategy,
PutObjectGuard, get_concurrency_aware_buffer_size, get_concurrency_manager, get_put_concurrency_aware_buffer_size,
PutObjectGuard, SNOWBALL_STAGING_BYTES_LIMIT, get_concurrency_aware_buffer_size, get_concurrency_manager,
get_put_concurrency_aware_buffer_size,
};
}
+1 -1
View File
@@ -16445,7 +16445,7 @@ fn object_mutation_entrypoints_call_reserved_prefix_guard() {
"if let Err(err) = validate_table_catalog_object_mutation(&bucket, &obj_id.key).await",
"validate_table_catalog_object_mutation(&bucket, &object).await?;",
"validate_object_key(&key, \"PUT\")?;\n validate_table_catalog_object_mutation(&bucket, &key).await?;",
"validate_table_catalog_object_mutation(&bucket, &fpath).await?;",
"extract_try!(validate_table_catalog_object_mutation(&bucket, &fpath).await);",
] {
assert!(source.contains(expected), "missing object mutation guard: {expected}");
}