Compare commits

..

58 Commits

Author SHA1 Message Date
overtrue ba1386a0fc test(multipart): preserve failures before fault barriers 2026-10-27 06:59:54 +08:00
overtrue 6c42c94f17 fix(shutdown): join actual TLS reload work before retirement 2026-10-27 06:39:13 +08:00
overtrue 17ce7097c4 fix(shutdown): retain HTTP child tasks through real retirement 2026-10-27 04:25:00 +08:00
overtrue 1fbda624c9 fix(shutdown): preserve maintenance and frontend join failures 2026-10-27 02:30:45 +08:00
overtrue 41befd4978 fix(heal): admit inventory probes and retain replacement work 2026-10-27 02:30:45 +08:00
overtrue 0856c5a3cb fix(startup): retain shutdown work and preserve deadline failures 2026-10-27 01:14:42 +08:00
overtrue 3b4a35a4b6 fix(heal): yield node admission while accepted native work drains 2026-10-27 01:14:42 +08:00
overtrue 057d05964b build: pin profiling sources and trust fixed ownership dependencies 2026-10-27 01:14:42 +08:00
overtrue d749a845d2 fix(heal): admit resume scans and retain replacement workers 2026-10-26 19:57:31 +08:00
overtrue 4a200357a6 fix(heal): drain channel commands before stopping MRF 2026-10-26 19:18:59 +08:00
overtrue c398c45ae5 test(heal): verify shutdown at native read boundaries 2026-10-26 18:06:44 +08:00
overtrue 7c5ec6fb66 fix(heal): join manager workers during shutdown 2026-10-26 17:22:39 +08:00
overtrue f139c19f8a test(heal): isolate process-global MRF consumer fixture 2026-10-26 15:10:12 +08:00
overtrue d49309b117 fix(heal): retain and join the MRF consumer on shutdown 2026-10-26 13:29:39 +08:00
overtrue 2188ad2574 fix(scanner): account namespace envelope decode storage 2026-10-26 13:17:13 +08:00
overtrue 4f78887475 fix(scanner): budget namespace frame read staging 2026-10-26 12:34:34 +08:00
overtrue 7b1ff0da45 fix(scanner): retain the enabled supervisor task handle 2026-10-26 12:15:33 +08:00
overtrue 94f344a7d4 fix(rpc): budget namespace scanner responses 2026-10-26 11:15:13 +08:00
overtrue ff834ac98f feat(scanner): measure cache copy allocation requests 2026-10-26 08:55:50 +08:00
overtrue 3f0e592264 perf(rpc): reuse reserved response reader storage 2026-10-26 08:05:09 +08:00
overtrue c15af8e16d fix(rpc): reserve background scanner pipe storage 2026-10-26 07:37:15 +08:00
overtrue cc05fd0e37 perf(rio): reuse reserved HTTP writer storage 2026-10-26 06:58:11 +08:00
overtrue 083e7e7350 fix(storage): retain inline and HTTP writer memory reservations 2026-10-26 06:34:37 +08:00
overtrue 6f1875e021 test(scanner): verify samples and clean concurrent collectors 2026-10-26 05:52:11 +08:00
overtrue 9a03007726 fix(ecstore): account for heal and RPC reader buffers 2026-10-26 05:52:11 +08:00
overtrue 28cad76ce3 fix(storage): retain memory charges for direct-write buffers 2026-10-26 01:20:14 +08:00
overtrue d4d071869c fix(storage): account for native io_uring read allocations 2026-10-26 01:05:40 +08:00
overtrue 88cc1a3f54 fix(storage): retain memory charges for background read results 2026-10-26 00:44:26 +08:00
overtrue d6e8973e59 fix(storage): account for retained background stream buffers 2026-10-26 00:01:37 +08:00
overtrue 6845775668 fix(ecstore): retain physical ownership during durable rename sync 2026-10-25 22:05:26 +08:00
overtrue 0d28dcaf37 fix(ecstore): retain resources through whole-file native I/O 2026-10-25 21:35:12 +08:00
overtrue fbd2bd2ca1 fix(ecstore): enforce receiver-side background admission 2026-10-25 20:56:51 +08:00
overtrue 39ac7321aa fix(ecstore): retain native io_uring read ownership 2026-10-25 18:53:04 +08:00
overtrue e161e65147 fix(ecstore): retain staged metadata write ownership 2026-10-25 17:26:39 +08:00
overtrue 9bcc86f49f fix(ecstore): retain resources during positioned reads 2026-10-25 16:57:16 +08:00
overtrue 564c4354c9 fix(storage): retain physical ownership during bitrot reads 2026-10-25 16:32:08 +08:00
overtrue e07caee90c fix(storage): retain resources through native filesystem jobs 2026-10-25 16:12:13 +08:00
overtrue 4a41482a18 chore: align physical admission with the updated node base 2026-10-25 15:42:30 +08:00
overtrue 040720aa58 chore: sync node admission with current main 2026-10-25 15:42:11 +08:00
overtrue 136296c76b chore: integrate main disk dispatch with physical admission 2026-10-25 15:35:27 +08:00
overtrue 5158b16917 fix(storage): retain physical resources through streamed reads 2026-10-25 14:57:34 +08:00
overtrue 3520821376 fix(storage): retain resources through buffered writer I/O 2026-10-25 14:32:29 +08:00
overtrue 2924e37351 fix(storage): retain resources through direct writer I/O 2026-10-25 14:04:56 +08:00
overtrue cedaa66673 fix(storage): retain resources during native format probes 2026-10-25 13:30:07 +08:00
overtrue 723e6fa58e feat(heal): admit physical resources before object repair 2026-10-25 13:04:18 +08:00
overtrue 058699c57a feat(storage): admit auxiliary physical resources without waiting 2026-10-25 12:35:48 +08:00
overtrue 1c7d0d057e fix(storage): retain only native disk resource subsets 2026-10-25 12:12:41 +08:00
overtrue 23c62f5dd7 feat(scanner): share physical topology across disk plans 2026-10-25 11:19:36 +08:00
overtrue 95f21f0267 feat(concurrency): combine preallocated resource selections 2026-10-25 11:19:36 +08:00
overtrue 49eb74f209 feat(scanner): gate background reads by physical device 2026-10-25 11:19:31 +08:00
overtrue d954886cef feat(concurrency): acquire physical groups before node work 2026-10-25 11:19:31 +08:00
overtrue 1fd619992f fix(scanner): retain resource ownership in native filesystem jobs 2026-10-25 11:19:31 +08:00
overtrue 7947978a45 feat(concurrency): retain bounded physical resource subsets 2026-10-25 11:19:31 +08:00
overtrue b2006b78e3 fix(concurrency): declare Tokio time dependency 2026-10-25 11:19:24 +08:00
overtrue 1123029eda test(scanner): attest adaptive admission in process receipts 2026-10-25 11:19:24 +08:00
overtrue 529b440f67 fix(scanner): yield cache leases for shared node admission 2026-10-25 11:19:24 +08:00
overtrue 4ed33f68a8 fix(scanner): drain candidates after listing completion 2026-10-25 11:19:24 +08:00
overtrue 28fbe69909 feat(scanner): share node admission with background heal 2026-10-25 11:19:24 +08:00
267 changed files with 28909 additions and 58577 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
sha256-linux=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
sha256-darwin=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
-15
View File
@@ -25,8 +25,6 @@ on:
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/storage-api/src/bucket.rs"
- "crates/storage-api/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
@@ -50,8 +48,6 @@ on:
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/storage-api/src/bucket.rs"
- "crates/storage-api/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
@@ -144,17 +140,6 @@ jobs:
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
- name: Test artifact prefix Windows file-name rules
shell: pwsh
run: |
$testName = 'bucket::tests::artifact_prefix_preserves_host_file_name_rules'
cargo test --locked -p rustfs-storage-api --lib $testName -- --exact --nocapture --color never 2>&1 |
Tee-Object -Variable testOutput
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (($testOutput -join "`n") -notmatch 'test result: ok\. 1 passed; 0 failed; 0 ignored;') {
throw 'Expected exactly one passing artifact prefix test on Windows'
}
- name: Test Windows file-name rules
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
Generated
+4 -10
View File
@@ -5236,8 +5236,7 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62f2351902fa259b758c7303505273393f6b08172ab5bdd5739a2d2bc6d554e8"
source = "git+https://github.com/rustfs/hotpath.git?rev=4d7a2f15758e65579c7c80f3c14b8c90b7760d3b#4d7a2f15758e65579c7c80f3c14b8c90b7760d3b"
dependencies = [
"arc-swap",
"async-channel",
@@ -5271,14 +5270,12 @@ dependencies = [
[[package]]
name = "hotpath-drain"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83dab2340134d9976d62f9f5172f9c6584c7f521b092f258e0e58e412e160b53"
source = "git+https://github.com/rustfs/hotpath.git?rev=4d7a2f15758e65579c7c80f3c14b8c90b7760d3b#4d7a2f15758e65579c7c80f3c14b8c90b7760d3b"
[[package]]
name = "hotpath-macros"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a04bbe5701b097f224a08187a78b75bb9d9b4bbaff6a279c2a50ba73edbb7aac"
source = "git+https://github.com/rustfs/hotpath.git?rev=4d7a2f15758e65579c7c80f3c14b8c90b7760d3b#4d7a2f15758e65579c7c80f3c14b8c90b7760d3b"
dependencies = [
"proc-macro2",
"quote",
@@ -10298,7 +10295,6 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -10688,7 +10684,6 @@ dependencies = [
"hotpath",
"insta",
"rustfs-filemeta",
"rustfs-utils",
"serde",
"serde_json",
"time",
@@ -10833,8 +10828,7 @@ dependencies = [
[[package]]
name = "rustfs-uring"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7deda770b7981ee3bfaa202ecd18625fc794c962b8d58cd4f436952b7e66dcf"
source = "git+https://github.com/rustfs/uring.git?rev=eb8fc160e71d804427de1e905f5c226d580f71bf#eb8fc160e71d804427de1e905f5c226d580f71bf"
dependencies = [
"io-uring",
"libc",
+4 -1
View File
@@ -384,7 +384,7 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.7.2" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.29.1", default-features = false }
hotpath = { version = "=0.29.1", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
@@ -424,6 +424,9 @@ strip = "none"
# Pin upstream fixes until compatible crates.io releases are available.
[patch.crates-io]
# Keep the ordered drain API fixed while its upstream patch is reviewed.
hotpath = { git = "https://github.com/rustfs/hotpath.git", rev = "4d7a2f15758e65579c7c80f3c14b8c90b7760d3b" }
hotpath-drain = { git = "https://github.com/rustfs/hotpath.git", rev = "4d7a2f15758e65579c7c80f3c14b8c90b7760d3b" }
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
+1 -1
View File
@@ -26,7 +26,7 @@ rustfs-io-core = { workspace = true }
serde = { workspace = true, features = ["derive"] }
# Async runtime
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time"] }
# Logging
tracing = { workspace = true }
+83 -14
View File
@@ -10,6 +10,10 @@ use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
use tokio::time::Instant;
pub mod permit_set;
const MAX_PERMIT_REFERENCES: usize = usize::MAX / 2;
/// A resource owner publishes a capacity; running units retain their permits
/// after a reduction. Lease slots are reserved once, with no per-unit Arc
/// allocation. Tokio wait queues may lazily initialize platform mutex storage
@@ -36,6 +40,7 @@ pub struct AdaptiveAdmission {
maximum: u32,
slots: Box<[PermitSlots]>,
next_chunk: AtomicUsize,
completed: AtomicU64,
}
struct PermitSlots {
@@ -61,10 +66,12 @@ pub struct AdaptivePermit {
impl Clone for AdaptivePermit {
fn clone(&self) -> Self {
// Every slot reference owns a strong owner reference. Clone the Arc
// first, so its overflow protection also bounds the slot refcount.
// Subset carriers can retain slot references through a shared owner,
// so the slot count needs its own bound in addition to Arc's guard.
let owner = Arc::clone(&self.owner);
owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed);
if owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed) >= MAX_PERMIT_REFERENCES {
std::process::abort();
}
Self { owner, slot: self.slot }
}
}
@@ -114,11 +121,22 @@ impl NodeAdmission {
/// Publish feedback with this owner's actual completed-work counter.
pub fn update(&self, controller: &mut FeedbackController, observation: Option<FeedbackObservation>) -> FeedbackDecision {
self.update_with_probe(controller, observation, true)
}
/// Coordinate growth with other resource controllers in the same window.
/// Protective reductions never depend on permission to probe.
pub fn update_with_probe(
&self,
controller: &mut FeedbackController,
observation: Option<FeedbackObservation>,
allow_probe: bool,
) -> FeedbackDecision {
let observation = observation.map(|mut observation| {
observation.completed_units = self.completed();
observation
});
controller.update(&self.capacity, observation)
controller.update_with_probe(&self.capacity, observation, allow_probe)
}
/// Current leased scheduling cap and held async units.
@@ -249,16 +267,31 @@ impl FeedbackController {
/// Consume one sample and publish a three-second lease to the same gate.
/// If this function stops being called, admission expires independently.
pub fn update(&mut self, gate: &AdaptiveAdmission, observation: Option<FeedbackObservation>) -> FeedbackDecision {
self.update_with_probe(gate, observation, true)
}
/// Allow at most the sampling owner's selected controller to probe upward.
pub fn update_with_probe(
&mut self,
gate: &AdaptiveAdmission,
observation: Option<FeedbackObservation>,
allow_probe: bool,
) -> FeedbackDecision {
let observation = observation.map(|mut observation| {
observation.max_concurrency = observation.max_concurrency.min(gate.maximum);
observation
});
let decision = self.decide(Instant::now(), observation);
let decision = self.decide_with_probe(Instant::now(), observation, allow_probe);
gate.set_sampled_limit(decision.limit, Duration::from_secs(3));
decision
}
fn decide(&mut self, now: Instant, observation: Option<FeedbackObservation>) -> FeedbackDecision {
fn decide_with_probe(
&mut self,
now: Instant,
observation: Option<FeedbackObservation>,
allow_probe: bool,
) -> FeedbackDecision {
let Some(observation) = observation else {
self.previous = None;
self.stable_since = None;
@@ -303,7 +336,8 @@ impl FeedbackController {
FeedbackReason::NewBaseline
} else {
let since = *self.stable_since.get_or_insert(now);
if advanced
if allow_probe
&& advanced
&& now.saturating_duration_since(since) >= Duration::from_secs(8)
&& now.saturating_duration_since(self.last_change) >= Duration::from_secs(8)
&& self.limit < observation.max_concurrency
@@ -325,6 +359,13 @@ impl FeedbackController {
}
impl AdaptiveAdmission {
/// Successful async operations using this physical owner. Native clones
/// retain capacity but never count another completion.
pub fn completed(&self) -> Option<u64> {
let value = self.completed.load(Ordering::Acquire);
(value != u64::MAX).then_some(value)
}
/// Construct a bounded owner, with zero initial capacity allowed for unknown
/// input. The immutable maximum reserves at most 65,535 lease slots once;
/// acquiring or cloning a permit does not allocate a per-unit Arc.
@@ -351,6 +392,7 @@ impl AdaptiveAdmission {
maximum: maximum_u32,
slots,
next_chunk: AtomicUsize::new(0),
completed: AtomicU64::new(0),
})
}
@@ -466,6 +508,21 @@ impl AdaptiveAdmission {
self.slots[slot / 64].free.fetch_or(1_u64 << (slot % 64), Ordering::Release);
}
fn release_reference(&self, slot: usize) {
self.release_reference_with(slot, || {});
}
fn release_reference_with(&self, slot: usize, before_release: impl FnOnce()) {
if self.slots[slot / 64].references[slot % 64].fetch_sub(1, Ordering::AcqRel) != 1 {
return;
}
before_release();
// Publish the free slot before exposing another admission capacity.
self.release_slot(slot);
self.state.fetch_sub(1, Ordering::AcqRel);
self.changed.notify_one();
}
/// Wait for capacity. The future owns no permit until it returns; dropping
/// a pending future leaves capacity untouched. Wrap in the caller's existing
/// cancellation/deadline select before acquiring any storage commit locks.
@@ -493,13 +550,7 @@ impl AdaptiveAdmission {
impl Drop for AdaptivePermit {
fn drop(&mut self) {
if self.owner.slots[self.slot / 64].references[self.slot % 64].fetch_sub(1, Ordering::AcqRel) != 1 {
return;
}
// Publish the free slot before exposing another admission capacity.
self.owner.release_slot(self.slot);
self.owner.state.fetch_sub(1, Ordering::AcqRel);
self.owner.changed.notify_one();
self.owner.release_reference(self.slot);
}
}
@@ -861,6 +912,24 @@ mod tests {
}
}
#[tokio::test(start_paused = true)]
async fn shared_probe_permission_blocks_growth_but_never_protective_cuts() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bound"), 0);
let mut controller = FeedbackController::new();
controller.update_with_probe(&gate, Some(healthy(0)), false);
for completed in 1..=20 {
tokio::time::advance(Duration::from_secs(1)).await;
assert_eq!(controller.update_with_probe(&gate, Some(healthy(completed)), false).limit, 1);
}
tokio::time::advance(Duration::from_secs(1)).await;
assert_eq!(controller.update_with_probe(&gate, Some(healthy(21)), true).reason, FeedbackReason::Probe);
assert_eq!(gate.snapshot().limit, 2);
let mut busy = healthy(22);
busy.pressure = ResourcePressure::Busy;
assert_eq!(controller.update_with_probe(&gate, Some(busy), false).limit, 1);
assert_eq!(controller.update_with_probe(&gate, None, false).limit, 0);
}
#[tokio::test(start_paused = true)]
async fn controller_grows_one_unit_per_cooldown_and_cuts_pressure_immediately() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
File diff suppressed because it is too large Load Diff
+102 -53
View File
@@ -24,6 +24,14 @@ use std::{
time::{Duration, SystemTime},
};
/// Copy an existing cache-model value without including surrounding work.
#[inline]
pub fn clone_cache_value<T: ToOwned + ?Sized>(source: &T) -> T::Owned {
#[cfg(feature = "hotpath-alloc")]
let _copy_scope = hotpath::MeasurementGuardSync::new("cache_model.owned_copy", false, false);
source.to_owned()
}
/// Maximum amount a persisted `last_update` may lead the local wall clock before the
/// persisted timestamp is treated as untrustworthy.
///
@@ -342,7 +350,7 @@ impl UnknownTierStats {
self.diagnostics_dropped = checked_saturating_add(1, self.diagnostics_dropped, &mut self.counter_overflowed);
continue;
}
self.diagnostics.push(diagnostic.clone());
self.diagnostics.push(clone_cache_value(diagnostic));
}
}
if !self.diagnostics.is_empty() {
@@ -367,7 +375,7 @@ impl UnknownTierStats {
if !self.fits_add(other) {
return None;
}
let mut merged = self.clone();
let mut merged = clone_cache_value(self);
merged.merge(other);
Some(merged)
}
@@ -407,7 +415,7 @@ impl AllTierStats {
if st.is_empty() {
continue;
}
let entry = self.tiers.entry(tier.clone()).or_default();
let entry = self.tiers.entry(clone_cache_value(tier)).or_default();
*entry = entry.add(st);
}
}
@@ -437,7 +445,7 @@ impl AllTierStats {
.tiers
.keys()
.filter(|tier| tier.as_str() != "STANDARD" && tier.as_str() != "REDUCED_REDUNDANCY" && !known.contains(tier.as_str()))
.cloned()
.map(clone_cache_value)
.collect();
for tier in retired_tiers {
if let Some(stats) = self.tiers.remove(&tier) {
@@ -1234,10 +1242,10 @@ impl DataUsageEntry {
}
pub fn add_child(&mut self, hash: &DataUsageHash) {
if self.children.contains(&hash.key()) {
if self.children.contains(&clone_cache_value(&hash.0)) {
return;
}
self.children.insert(hash.key());
self.children.insert(clone_cache_value(&hash.0));
}
pub fn merge(&mut self, other: &DataUsageEntry) {
@@ -1254,7 +1262,7 @@ impl DataUsageEntry {
s_rep.replica_size += o_rep.replica_size;
s_rep.replica_count += o_rep.replica_count;
for (arn, stat) in o_rep.targets.iter() {
let st = s_rep.targets.entry(arn.clone()).or_default();
let st = s_rep.targets.entry(clone_cache_value(arn)).or_default();
st.pending_size += stat.pending_size;
st.replicated_size += stat.replicated_size;
st.failed_size += stat.failed_size;
@@ -1448,7 +1456,7 @@ pub fn prefix_usage_in_cache(
} else {
format!("{bucket}/{prefix}")
};
let entry = cache.get(&hash_path(&root).key())?.clone();
let entry = clone_cache_value(cache.get(&clone_cache_value(&hash_path(&root).0))?);
let usage = PrefixUsageSummary::from_entry(&flatten_entry(cache, &entry, 0)?);
@@ -1591,38 +1599,38 @@ pub struct DataUsageCache {
impl DataUsageCache {
pub fn replace(&mut self, path: &str, parent: &str, e: DataUsageEntry) {
let hash = hash_path(path);
self.cache.insert(hash.key(), e);
self.cache.insert(clone_cache_value(&hash.0), e);
if !parent.is_empty() {
let phash = hash_path(parent);
let p = {
let p = self.cache.entry(phash.key()).or_default();
let p = self.cache.entry(clone_cache_value(&phash.0)).or_default();
p.add_child(&hash);
p.clone()
clone_cache_value(p)
};
self.cache.insert(phash.key(), p);
self.cache.insert(clone_cache_value(&phash.0), p);
}
}
pub fn replace_hashed(&mut self, hash: &DataUsageHash, parent: &Option<DataUsageHash>, e: &DataUsageEntry) {
self.cache.insert(hash.key(), e.clone());
self.cache.insert(clone_cache_value(&hash.0), clone_cache_value(e));
if let Some(parent) = parent {
self.cache.entry(parent.key()).or_default().add_child(hash);
self.cache.entry(clone_cache_value(&parent.0)).or_default().add_child(hash);
}
}
pub fn find(&self, path: &str) -> Option<DataUsageEntry> {
self.cache.get(&hash_path(path).key()).cloned()
self.cache.get(&clone_cache_value(&hash_path(path).0)).map(clone_cache_value)
}
pub fn find_children_copy(&mut self, h: DataUsageHash) -> DataUsageHashMap {
self.cache.entry(h.string()).or_default().children.clone()
clone_cache_value(&self.cache.entry(clone_cache_value(&h.0)).or_default().children)
}
pub fn flatten(&self, root: &DataUsageEntry) -> DataUsageEntry {
let mut root = root.clone();
for id in root.children.clone().iter() {
let mut root = clone_cache_value(root);
for id in clone_cache_value(&root.children).iter() {
if let Some(e) = self.cache.get(id) {
let mut e = e.clone();
let mut e = clone_cache_value(e);
if !e.children.is_empty() {
e = self.flatten(&e);
}
@@ -1634,16 +1642,16 @@ impl DataUsageCache {
}
pub fn copy_with_children(&mut self, src: &DataUsageCache, hash: &DataUsageHash, parent: &Option<DataUsageHash>) {
if let Some(e) = src.cache.get(&hash.string()) {
self.cache.insert(hash.key(), e.clone());
if let Some(e) = src.cache.get(&clone_cache_value(&hash.0)) {
self.cache.insert(clone_cache_value(&hash.0), clone_cache_value(e));
for ch in e.children.iter() {
if *ch == hash.key() {
if *ch == clone_cache_value(&hash.0) {
return;
}
self.copy_with_children(src, &DataUsageHash(ch.to_string()), &Some(hash.clone()));
self.copy_with_children(src, &DataUsageHash(clone_cache_value(ch)), &Some(clone_cache_value(hash)));
}
if let Some(parent) = parent {
let p = self.cache.entry(parent.key()).or_default();
let p = self.cache.entry(clone_cache_value(&parent.0)).or_default();
p.add_child(hash);
}
}
@@ -1651,14 +1659,14 @@ impl DataUsageCache {
pub fn delete_recursive(&mut self, hash: &DataUsageHash) {
let mut need_remove = Vec::new();
if let Some(v) = self.cache.get(&hash.string()) {
if let Some(v) = self.cache.get(&clone_cache_value(&hash.0)) {
for child in v.children.iter() {
need_remove.push(child.clone());
need_remove.push(clone_cache_value(child));
}
}
self.cache.remove(&hash.string());
self.cache.remove(&clone_cache_value(&hash.0));
need_remove.iter().for_each(|child| {
self.delete_recursive(&DataUsageHash(child.to_string()));
self.delete_recursive(&DataUsageHash(clone_cache_value(child)));
});
}
@@ -1679,7 +1687,7 @@ impl DataUsageCache {
}
pub fn search_parent(&self, hash: &DataUsageHash) -> Option<DataUsageHash> {
let want = hash.key();
let want = clone_cache_value(&hash.0);
if let Some(last_index) = want.rfind('/')
&& let Some(v) = self.find(&want[0..last_index])
&& v.children.contains(&want)
@@ -1690,7 +1698,7 @@ impl DataUsageCache {
for (k, v) in self.cache.iter() {
if v.children.contains(&want) {
let found = DataUsageHash(k.clone());
let found = DataUsageHash(clone_cache_value(k));
return Some(found);
}
}
@@ -1698,7 +1706,7 @@ impl DataUsageCache {
}
pub fn is_compacted(&self, hash: &DataUsageHash) -> bool {
match self.cache.get(&hash.key()) {
match self.cache.get(&clone_cache_value(&hash.0)) {
Some(due) => due.compacted,
None => false,
}
@@ -1723,7 +1731,7 @@ impl DataUsageCache {
if self.cache.len() < limit {
return;
}
let top = hash_path(&self.info.name).key();
let top = clone_cache_value(&hash_path(&self.info.name).0);
let top_e = match self.find(&top) {
Some(e) => e,
None => return,
@@ -1750,7 +1758,7 @@ impl DataUsageCache {
}
pub fn reduce_children_of(&mut self, path: &DataUsageHash, limit: usize, compact_self: bool) {
let e = match self.cache.get(&path.key()) {
let e = match self.cache.get(&clone_cache_value(&path.0)) {
Some(e) => e,
None => return,
};
@@ -1760,13 +1768,13 @@ impl DataUsageCache {
}
if e.children.len() > limit && compact_self {
let mut flat = self.size_recursive(&path.key()).unwrap_or_default();
let mut flat = self.size_recursive(&clone_cache_value(&path.0)).unwrap_or_default();
flat.compacted = true;
self.delete_recursive(path);
self.replace_hashed(path, &None, &flat);
return;
}
let total = self.total_children_rec(&path.key());
let total = self.total_children_rec(&clone_cache_value(&path.0));
if total < limit {
return;
}
@@ -1779,12 +1787,12 @@ impl DataUsageCache {
let mut candidate_index = 0;
while remove > 0 && candidate_index < candidates.len() {
let e = &candidates[candidate_index];
let candidate = e.path.clone();
let candidate = clone_cache_value(&e.path);
if candidate == *path && !compact_self {
break;
}
let removing = self.total_children_rec(&candidate.key());
let mut flat = match self.size_recursive(&candidate.key()) {
let removing = self.total_children_rec(&clone_cache_value(&candidate.0));
let mut flat = match self.size_recursive(&clone_cache_value(&candidate.0)) {
Some(flat) => flat,
None => {
candidate_index += 1;
@@ -1821,7 +1829,7 @@ impl DataUsageCache {
if o.root().is_none() {
return;
}
*self = o.clone();
*self = clone_cache_value(o);
return;
};
@@ -1834,7 +1842,8 @@ impl DataUsageCache {
}
existing_root.merge(&other_root);
self.cache.insert(hash_path(&self.info.name).key(), existing_root);
self.cache
.insert(clone_cache_value(&hash_path(&self.info.name).0), existing_root);
let root_hash = self.root_hash();
for key in other_root.children.iter() {
@@ -1845,7 +1854,7 @@ impl DataUsageCache {
if let Some(existing) = self.cache.get_mut(key) {
existing.merge(&flat);
} else {
self.replace_hashed(&DataUsageHash(key.clone()), &Some(root_hash.clone()), &flat);
self.replace_hashed(&DataUsageHash(clone_cache_value(key)), &Some(clone_cache_value(&root_hash)), &flat);
}
}
}
@@ -1889,7 +1898,7 @@ impl DataUsageCache {
for (arn, stat) in rs.targets.iter() {
bui.replication_info.insert(
arn.clone(),
clone_cache_value(arn),
BucketTargetUsageInfo {
replication_pending_size: stat.pending_size,
replicated_size: stat.replicated_size,
@@ -1902,7 +1911,7 @@ impl DataUsageCache {
);
}
}
buckets_usage.insert(bucket_name.clone(), bui);
buckets_usage.insert(clone_cache_value(bucket_name), bui);
}
DataUsageInfo {
@@ -1934,20 +1943,20 @@ struct Inner {
}
fn add(data_usage_cache: &DataUsageCache, path: &DataUsageHash, candidates: &mut Vec<Inner>) -> usize {
let e = match data_usage_cache.cache.get(&path.key()) {
let e = match data_usage_cache.cache.get(&clone_cache_value(&path.0)) {
Some(e) => e,
None => return 0,
};
let mut objects = e.objects;
for ch in e.children.iter() {
objects += add(data_usage_cache, &DataUsageHash(ch.clone()), candidates);
objects += add(data_usage_cache, &DataUsageHash(clone_cache_value(ch)), candidates);
}
// Collect internal nodes (with children) as compaction candidates.
// Leaf nodes have no children to remove, so compacting them is a no-op.
if !e.children.is_empty() {
candidates.push(Inner {
objects,
path: path.clone(),
path: clone_cache_value(path),
});
}
objects
@@ -1955,7 +1964,7 @@ fn add(data_usage_cache: &DataUsageCache, path: &DataUsageHash, candidates: &mut
fn mark(duc: &DataUsageCache, entry: &DataUsageEntry, found: &mut HashSet<String>) {
for k in entry.children.iter() {
found.insert(k.to_string());
found.insert(clone_cache_value(k));
if let Some(ch) = duc.cache.get(k) {
mark(duc, ch, found);
}
@@ -2402,11 +2411,11 @@ impl SizeSummary {
}
for entry in &other.size_reconciliation {
self.record_size_reconciliation(entry.clone());
self.record_size_reconciliation(clone_cache_value(entry));
}
self.size_reconciliation_truncated |= other.size_reconciliation_truncated;
for scope in &other.reconciliation_scopes {
self.record_reconciliation_scope(&scope.bucket, &scope.object);
self.record_reconciliation_scope_with(&scope.bucket, &scope.object, || clone_cache_value(scope));
}
}
@@ -2431,6 +2440,13 @@ impl SizeSummary {
/// Mark one object scope as refreshed. Duplicate scopes are suppressed so
/// merging summaries remains bounded and deterministic.
pub fn record_reconciliation_scope(&mut self, bucket: &str, object: &str) {
self.record_reconciliation_scope_with(bucket, object, || SizeReconciliationScope {
bucket: bucket.to_string(),
object: object.to_string(),
});
}
fn record_reconciliation_scope_with(&mut self, bucket: &str, object: &str, build: impl FnOnce() -> SizeReconciliationScope) {
if !self
.reconciliation_scopes
.iter()
@@ -2440,10 +2456,7 @@ impl SizeSummary {
self.size_reconciliation_truncated = true;
return;
}
self.reconciliation_scopes.push(SizeReconciliationScope {
bucket: bucket.to_string(),
object: object.to_string(),
});
self.reconciliation_scopes.push(build());
}
}
}
@@ -3373,6 +3386,42 @@ mod tests {
assert_eq!(summary1.versions, 15);
}
#[test]
fn test_reconciliation_scope_builds_only_after_admission() {
let mut summary = SizeSummary::new();
let builds = std::cell::Cell::new(0);
let build = || {
builds.set(builds.get() + 1);
SizeReconciliationScope {
bucket: "bucket".to_string(),
object: "object".to_string(),
}
};
summary.record_reconciliation_scope_with("bucket", "object", build);
assert_eq!(builds.get(), 1, "an admitted scope is copied once");
assert_eq!(summary.reconciliation_scopes.len(), 1);
assert_eq!(summary.reconciliation_scopes[0].bucket, "bucket");
assert_eq!(summary.reconciliation_scopes[0].object, "object");
summary.record_reconciliation_scope_with("bucket", "object", build);
assert_eq!(builds.get(), 1, "duplicate scopes must not be copied");
assert_eq!(summary.reconciliation_scopes.len(), 1);
assert!(!summary.size_reconciliation_truncated);
for index in 1..1024 {
summary.record_reconciliation_scope("bucket", &index.to_string());
}
summary.record_reconciliation_scope_with("bucket", "object", build);
assert_eq!(builds.get(), 1, "a duplicate at capacity must not be copied");
assert!(!summary.size_reconciliation_truncated);
summary.record_reconciliation_scope_with("bucket", "overflow", build);
assert_eq!(builds.get(), 1, "a rejected scope must not be copied");
assert_eq!(summary.reconciliation_scopes.len(), 1024);
assert!(summary.size_reconciliation_truncated);
}
#[test]
fn test_size_histogram_compat_rollup_sums_all_sub_buckets() {
let mut hist = SizeHistogram::default();
+2 -2
View File
@@ -200,8 +200,8 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
# Distributed 4-node 4-disk lane includes physical-worker fault injection and
# scanner_heal_topology_ cases; both require the fault-test hooks server.
# Distributed 4-node 4-disk lane uses the fault-test hooks server; the
# scanner_heal_topology_ fault cases fail closed without it.
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
@@ -1,378 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, s3_probe_client, unique_bucket, wait_for_ready,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const PUBLIC_INTERNAL_ERROR: &str = "We encountered an internal error, please try again.";
#[derive(Debug, PartialEq, Eq)]
pub(super) struct PhysicalEntry {
identity: (u64, u64),
changed: (i64, i64),
modified: (i64, i64),
bytes: Option<Vec<u8>>,
}
// Include directory identity/ctime as well as file bytes. Creating and removing
// a temporary marker/rollback file cannot hide behind a restored final xl.meta.
// Reads change atime, which is deliberately excluded.
pub(super) fn physical_snapshot(dist: &DistCluster, bucket: &str) -> TestResult<BTreeMap<(usize, PathBuf), PhysicalEntry>> {
let mut snapshot = BTreeMap::new();
for (node, instance) in dist.cluster.nodes.iter().enumerate() {
let root = Path::new(&instance.data_dirs[0]).join(bucket);
for entry in walkdir::WalkDir::new(&root) {
let entry = entry?;
let metadata = std::fs::symlink_metadata(entry.path())?;
assert!(!metadata.file_type().is_symlink(), "unexpected fixture symlink");
snapshot.insert(
(node, entry.path().strip_prefix(&root)?.to_path_buf()),
PhysicalEntry {
identity: (metadata.dev(), metadata.ino()),
changed: (metadata.ctime(), metadata.ctime_nsec()),
modified: (metadata.mtime(), metadata.mtime_nsec()),
bytes: if metadata.is_dir() {
None
} else {
Some(std::fs::read(entry.path())?)
},
},
);
}
}
Ok(snapshot)
}
struct Version {
key: String,
id: String,
body: Vec<u8>,
manifests: Vec<VersionShardCensus>,
}
impl Version {
async fn put(dist: &DistCluster, bucket: &str, key: &str, seed: &str) -> TestResult<Self> {
let body = payload_for(seed, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(key)
.body(body.clone().into())
.send(),
)
.await??;
let id = output.version_id().ok_or("fixture PUT omitted version ID")?.to_owned();
let manifests = dist
.cluster
.nodes
.iter()
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, key, Some(&id)))
.collect::<TestResult<Vec<_>>>()?;
assert!(
manifests
.iter()
.all(|manifest| manifest.is_complete() && !manifest.expected_part_numbers.is_empty())
);
Ok(Self {
key: key.to_owned(),
id,
body,
manifests,
})
}
}
fn target(key: &str, version: Option<&str>) -> TestResult<ObjectIdentifier> {
Ok(ObjectIdentifier::builder()
.key(key)
.set_version_id(version.map(str::to_owned))
.build()?)
}
async fn assert_fixture(
dist: &DistCluster,
bucket: &str,
prefix: &str,
versions: &[Version],
marker: (&str, &str),
) -> TestResult {
let expected: BTreeSet<_> = versions
.iter()
.map(|version| (version.key.clone(), version.id.clone()))
.collect();
for node in 0..dist.cluster.nodes.len() {
let client = dist.client(node)?;
let listed = timeout(
Duration::from_secs(30),
client.list_object_versions().bucket(bucket).prefix(prefix).send(),
)
.await??;
let actual: BTreeSet<_> = listed
.versions()
.iter()
.map(|version| {
(
version.key().unwrap_or_default().to_owned(),
version.version_id().unwrap_or_default().to_owned(),
)
})
.collect();
assert_eq!(listed.versions().len(), versions.len());
assert_eq!(actual, expected, "delete changed exact data versions on node {node}");
assert_eq!(listed.delete_markers().len(), 1);
assert_eq!(listed.delete_markers()[0].key(), Some(marker.0));
assert_eq!(listed.delete_markers()[0].version_id(), Some(marker.1));
assert_eq!(listed.delete_markers()[0].is_latest(), Some(true));
for version in versions {
assert_exact_version(&client, bucket, &version.key, &version.id, &version.body).await?;
let disk = Path::new(&dist.cluster.nodes[node].data_dirs[0]);
assert!(
census_object_version_on_disk(disk, bucket, &version.key, Some(&version.id))?
.matches_manifest(&version.manifests[node])
);
}
}
Ok(())
}
async fn assert_single_denied(dist: &DistCluster, node: usize, bucket: &str, key: &str, version: Option<&str>) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
// One attempt: a later retry must not conceal a first local mutation.
let client = s3_probe_client(&dist.client(node)?);
let error = timeout(
Duration::from_secs(30),
client
.delete_object()
.bucket(bucket)
.key(key)
.set_version_id(version.map(str::to_owned))
.send(),
)
.await?
.expect_err("ordinary deletion succeeded in a managed bucket");
let service = error
.as_service_error()
.ok_or("delete failed without an S3 service rejection")?;
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(500));
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(PUBLIC_INTERNAL_ERROR));
assert!(
physical_snapshot(dist, bucket)? == before,
"first delete through node {node} wrote or rolled back managed data"
);
Ok(())
}
async fn assert_batch_denied(dist: &DistCluster, node: usize, bucket: &str, objects: &[ObjectIdentifier]) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
let client = s3_probe_client(&dist.client(node)?);
let response = timeout(
Duration::from_secs(30),
client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects.to_vec())).quiet(false).build()?)
.send(),
)
.await??;
assert!(response.deleted().is_empty(), "managed batch reported a successful delete");
assert_eq!(response.errors().len(), objects.len(), "batch must reject every requested occurrence");
for (error, requested) in response.errors().iter().zip(objects) {
assert_eq!(error.key(), Some(requested.key()));
assert_eq!(error.version_id(), requested.version_id());
assert_eq!(error.code(), Some("InternalError"));
assert_eq!(error.message(), Some(PUBLIC_INTERNAL_ERROR));
}
assert!(
physical_snapshot(dist, bucket)? == before,
"first batch through node {node} wrote or rolled back managed data"
);
Ok(())
}
fn copy_existing_object(source: &Path, destination: &Path) -> TestResult {
assert!(!destination.exists());
for entry in walkdir::WalkDir::new(source) {
let entry = entry?;
let target = destination.join(entry.path().strip_prefix(source)?);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&target)?;
} else {
assert!(entry.file_type().is_file());
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
async fn ordinary_delete_control(dist: &DistCluster, bucket: &str) -> TestResult {
let client = s3_probe_client(&dist.client(0)?);
let key = "ordinary-observer-control.bin";
let version = Version::put(dist, bucket, key, key).await?;
let before = physical_snapshot(dist, bucket)?;
let deleted = client.delete_object().bucket(bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
let marker = deleted.version_id().ok_or("ordinary delete omitted marker ID")?;
assert!(
physical_snapshot(dist, bucket)? != before,
"physical observer missed real marker creation"
);
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(marker)
.send()
.await?;
// The logical version inventory is restored, but ctime/identity still
// expose the two writes. This checks the observer used by managed negatives.
let listed = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert_eq!(listed.versions().len(), 1);
assert_eq!(listed.versions()[0].version_id(), Some(version.id.as_str()));
assert!(listed.delete_markers().is_empty());
assert!(
physical_snapshot(dist, bucket)? != before,
"marker round-trip hid transient physical writes"
);
assert_exact_version(&client, bucket, key, &version.id, &version.body).await?;
client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version.id)
.send()
.await?;
assert!(
client
.list_object_versions()
.bucket(bucket)
.prefix(key)
.send()
.await?
.versions()
.is_empty()
);
let current = Version::put(dist, bucket, "ordinary-batch-current.bin", "current").await?;
let exact = Version::put(dist, bucket, "ordinary-batch-exact.bin", "exact").await?;
let response = client
.delete_objects()
.bucket(bucket)
.delete(
Delete::builder()
.objects(target(&current.key, None)?)
.objects(target(&exact.key, Some(&exact.id))?)
.objects(target(&exact.key, Some(&exact.id))?)
.build()?,
)
.send()
.await?;
assert!(response.errors().is_empty());
assert_eq!(response.deleted().len(), 3);
let listed = client
.list_object_versions()
.bucket(bucket)
.prefix(&current.key)
.send()
.await?;
assert_eq!(listed.versions().len(), 1);
assert_eq!(listed.delete_markers().len(), 1);
assert!(
client
.list_object_versions()
.bucket(bucket)
.prefix(&exact.key)
.send()
.await?
.versions()
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn four_node_artifact_delete_entrypoints_preserve_versions_and_physical_state() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (identity, admin) = create_artifact_attempt(&dist).await?;
let bucket = identity["bucket"]
.as_str()
.ok_or("managed receipt omitted bucket")?
.to_owned();
let prefix = identity["attempt_prefix"]
.as_str()
.ok_or("managed receipt omitted prefix")?
.to_owned();
let key = format!("{prefix}native.bin");
let mut versions = vec![
Version::put(&dist, &bucket, &key, "native-old").await?,
Version::put(&dist, &bucket, &key, "native-new").await?,
];
let ordinary = unique_bucket("delete-control");
dist.create_bucket(&ordinary).await?;
enable_versioning(&dist.client(0)?, &ordinary).await?;
// A new managed bucket cannot legitimately create a marker. Model existing
// persisted state from before adoption/upgrade using a real S3-generated
// ordinary object+marker, copied disk-for-disk in the same erasure set.
// Only this object's files move; bucket authority/configuration is untouched.
let marker_key = format!("{prefix}existing-marker.bin");
versions.push(Version::put(&dist, &ordinary, &marker_key, "existing-data").await?);
let output = dist
.client(2)?
.delete_object()
.bucket(&ordinary)
.key(&marker_key)
.send()
.await?;
assert_eq!(output.delete_marker(), Some(true));
let marker = output.version_id().ok_or("ordinary fixture omitted marker ID")?.to_owned();
dist.cluster.stop();
for node in &dist.cluster.nodes {
let disk = Path::new(&node.data_dirs[0]);
copy_existing_object(&disk.join(&ordinary).join(&marker_key), &disk.join(&bucket).join(&marker_key))?;
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
ordinary_delete_control(&dist, &ordinary).await?;
let batch = vec![
target(&key, None)?,
target(&key, Some(&versions[0].id))?,
target(&key, Some(&versions[1].id))?,
target(&marker_key, Some(&marker))?,
target(&key, Some(&versions[0].id))?,
target(&marker_key, Some(&versions[2].id))?,
];
for phase in ["open", "sealed", "restarted"] {
if phase == "sealed" {
confirm_seal(&dist, &admin, &json!({"version": 1, "target": identity}).to_string(), &identity).await?;
} else if phase == "restarted" {
dist.restart_with_current_binary().await?;
}
for node in 0..dist.cluster.nodes.len() {
assert_single_denied(&dist, node, &bucket, &key, None).await?;
assert_single_denied(&dist, node, &bucket, &key, Some(&versions[0].id)).await?;
assert_single_denied(&dist, node, &bucket, &marker_key, Some(&marker)).await?;
assert_batch_denied(&dist, node, &bucket, &batch).await?;
}
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
}
Ok(())
}
@@ -1,434 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_delete_test::physical_snapshot;
use super::artifact_seal_test::{ArtifactAdmin, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, wait_for_ready};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
signed_s3_request,
};
use futures::{StreamExt as _, TryStreamExt as _};
use http::{Method, StatusCode};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{collections::BTreeSet, time::Duration};
use tokio::time::timeout;
use uuid::Uuid;
const ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/inventory";
const BUDGET: Duration = Duration::from_secs(45);
struct Reader {
access: String,
secret: String,
}
impl Reader {
async fn create(dist: &DistCluster, target: &Value, action: &str) -> TestResult<Self> {
let reader = Self {
access: format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]),
secret: Uuid::new_v4().simple().to_string(),
};
let policy_name = format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]);
let bucket = target["bucket"].as_str().ok_or("target bucket")?;
let prefix = target["attempt_prefix"]
.as_str()
.ok_or("target prefix")?
.trim_end_matches('/');
let resource = if action == "s3:ListBucket" {
format!("arn:aws:s3:::{bucket}")
} else {
format!("arn:aws:s3:::{bucket}/{prefix}")
};
let policy =
json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":action,"Resource":resource}]}).to_string();
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, secret, &reader.access, &reader.secret).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &reader.access).await?;
Ok(reader)
}
async fn request(&self, dist: &DistCluster, target: &Value, invocation: Uuid) -> TestResult<reqwest::Response> {
timeout(
BUDGET,
signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(json!({"version":1,"invocation_id":invocation,"target":target}).to_string()),
Some("application/json"),
&self.access,
&self.secret,
),
)
.await?
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Frame {
version: u32,
invocation_id: Uuid,
target: Value,
sequence: u64,
payload: Payload,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum Payload {
Observation {
disk_id: Uuid,
key: String,
version_id: Uuid,
delete_marker: bool,
},
Complete {
members: usize,
observations: u64,
},
}
async fn read_frames(mut response: reqwest::Response) -> TestResult<(Vec<Frame>, bool)> {
let mut pending = Vec::new();
let mut frames = Vec::new();
let mut clean = true;
loop {
match timeout(BUDGET, response.chunk()).await? {
Ok(Some(chunk)) => {
pending.extend_from_slice(&chunk);
while let Some(end) = pending.iter().position(|byte| *byte == b'\n') {
assert!(end < 1024 * 1024, "HTTP inventory line exceeds encoded bound");
let line: Vec<_> = pending.drain(..=end).collect();
frames.push(serde_json::from_slice(&line[..end])?);
}
assert!(pending.len() < 1024 * 1024, "unbounded incomplete HTTP line");
}
Ok(None) => break,
Err(_) => {
clean = false;
break;
}
}
}
Ok((frames, clean && pending.is_empty()))
}
async fn seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value) -> TestResult {
let (status, body) = user
.request(
dist,
0,
"/rustfs/admin/v3/plugin-artifacts/seal-writes",
json!({"version":1,"target":target}).to_string(),
)
.await?;
assert_eq!(status, StatusCode::OK, "actual all-member closure: {body}");
assert_eq!(serde_json::from_str::<Value>(&body)?["all_writes_sealed"], true);
Ok(())
}
fn exact_inventory(frames: &[Frame], target: &Value, invocation: Uuid, versions: &BTreeSet<(String, Uuid, bool)>) {
let mut observed = BTreeSet::new();
let mut disks = BTreeSet::new();
let mut count = 0u64;
for (sequence, frame) in frames.iter().enumerate() {
assert_eq!(frame.version, 1);
assert_eq!(frame.invocation_id, invocation);
assert_eq!(&frame.target, target);
assert_eq!(frame.sequence, u64::try_from(sequence).expect("bounded fixture sequence"));
match &frame.payload {
Payload::Observation {
disk_id,
key,
version_id,
delete_marker,
} => {
assert!(!disk_id.is_nil() && !version_id.is_nil());
assert!(
observed.insert((*disk_id, key.clone(), *version_id, *delete_marker)),
"duplicate physical observation"
);
disks.insert(*disk_id);
count += 1;
}
Payload::Complete { members, observations } => {
assert_eq!(sequence + 1, frames.len(), "Complete must be the last HTTP frame");
assert_eq!(*members, 4);
assert_eq!(*observations, count);
}
}
}
assert!(
matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
"A03I3A_HTTP_COMPLETE"
);
if !versions.is_empty() {
assert_eq!(disks.len(), 4);
}
for disk in disks {
let actual: BTreeSet<_> = observed
.iter()
.filter(|(id, ..)| *id == disk)
.map(|(_, key, version, marker)| (key.clone(), *version, *marker))
.collect();
assert_eq!(&actual, versions, "A03I3A_EXACT_VERSIONS");
}
assert_eq!(observed.len(), versions.len() * 4);
}
#[tokio::test]
async fn artifact_inventory_http_exact_versions() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
let mut versions = BTreeSet::new();
for (name, body) in [
("same", b"first".to_vec()),
("same", b"second".to_vec()),
("zero", Vec::new()),
] {
let key = format!("{prefix}{name}");
let output = timeout(BUDGET, dist.client(1)?.put_object().bucket(bucket).key(&key).body(body.into()).send()).await??;
let version = Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?;
versions.insert((key, version, false));
}
seal(&dist, &admin, &target).await?;
// A wildcard action is still restricted to this exact attempt resource.
// The separate authority case exercises the dedicated action alone.
let reader = Reader::create(&dist, &target, "admin:*").await?;
let before = physical_snapshot(&dist, bucket)?;
let invocation = Uuid::new_v4();
let response = reader.request(&dist, &target, invocation).await?;
assert_eq!(response.status(), StatusCode::OK, "A03I3A_HTTP_ROUTE");
assert_eq!(
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
Some("application/x-ndjson")
);
let (frames, clean) = read_frames(response).await?;
assert!(clean, "Complete requires clean HTTP EOF");
assert!(frames.len() > 2, "real multi-frame HTTP body");
exact_inventory(&frames, &target, invocation, &versions);
assert!(
before == physical_snapshot(&dist, bucket)?,
"inventory mutated physical artifact namespace"
);
let (empty, empty_admin) = create_artifact_attempt(&dist).await?;
seal(&dist, &empty_admin, &empty).await?;
let empty_reader = Reader::create(&dist, &empty, "admin:*").await?;
let invocation = Uuid::new_v4();
let response = empty_reader.request(&dist, &empty, invocation).await?;
assert_eq!(response.status(), StatusCode::OK);
let (frames, clean) = read_frames(response).await?;
assert!(clean);
exact_inventory(&frames, &empty, invocation, &BTreeSet::new());
Ok(())
}
#[tokio::test]
async fn artifact_inventory_http_scoped_authority() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
seal(&dist, &admin, &target).await?;
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
let invocation = Uuid::new_v4();
let response = reader.request(&dist, &target, invocation).await?;
assert_eq!(response.status(), StatusCode::OK, "dedicated inventory authority");
let (frames, clean) = read_frames(response).await?;
assert!(clean);
exact_inventory(&frames, &target, invocation, &BTreeSet::new());
let mut other_prefix = target.clone();
other_prefix["attempt_prefix"] = json!(format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4()));
let mut other_bucket = target.clone();
other_bucket["bucket"] = json!("another-artifact-bucket");
for denied in [&other_prefix, &other_bucket] {
assert_eq!(
reader.request(&dist, denied, Uuid::new_v4()).await?.status(),
StatusCode::FORBIDDEN,
"A03I3A_SCOPED_AUTHORITY"
);
}
let (status, _) = admin
.request(
&dist,
0,
ROUTE,
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string(),
)
.await?;
assert_eq!(status, StatusCode::FORBIDDEN, "A03I3A_SCOPED_AUTHORITY seal-only user");
let listed = Reader::create(&dist, &target, "s3:ListBucket").await?;
dist.client_with_credentials(0, &listed.access, &listed.secret)?
.list_objects_v2()
.bucket(target["bucket"].as_str().ok_or("bucket")?)
.send()
.await?;
assert_eq!(
listed.request(&dist, &target, Uuid::new_v4()).await?.status(),
StatusCode::FORBIDDEN,
"A03I3A_SCOPED_AUTHORITY S3 list-only user"
);
let root_response = signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string()),
Some("application/json"),
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(root_response.status(), StatusCode::FORBIDDEN, "original scoped user required");
for body in [
json!({"version":1,"invocation_id":Uuid::nil(),"target":target}),
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target,"extra":true}),
] {
let response = signed_s3_request(
Method::POST,
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
Some(body.to_string()),
Some("application/json"),
&reader.access,
&reader.secret,
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "strict inventory request");
}
Ok(())
}
async fn assert_incomplete(response: reqwest::Response) -> TestResult<Vec<Frame>> {
assert_eq!(
response.status(),
StatusCode::OK,
"valid request must exercise the streaming failure boundary"
);
let (frames, _) = read_frames(response).await?;
assert!(
!frames.iter().any(|frame| matches!(frame.payload, Payload::Complete { .. })),
"A03I3A_NO_FALSE_COMPLETE"
);
Ok(frames)
}
#[tokio::test]
async fn artifact_inventory_http_incomplete_has_no_terminal() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, admin) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
// Real PUTs produce more data than a stalled HTTP/2 flow-control window.
futures::stream::iter(0..256)
.map(|index| {
let client = dist.client(1);
let key = format!("{prefix}slow/{index:04}/{}/{}", "a".repeat(190), "b".repeat(190));
async move {
let output = client?
.put_object()
.bucket(bucket)
.key(key)
.body(vec![1].into())
.send()
.await?;
assert!(!Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?.is_nil());
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
}
})
.buffer_unordered(8)
.try_collect::<Vec<_>>()
.await?;
seal(&dist, &admin, &target).await?;
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
dist.cluster.stop_node(3)?;
assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
let mut wrong = target.clone();
wrong["bucket_incarnation"] = json!(Uuid::new_v4());
assert_incomplete(reader.request(&dist, &wrong, Uuid::new_v4()).await?).await?;
// The configured endpoint map is ordered. Damage its final receiver, so
// earlier real observations are emitted before the physical scan fails.
let node = dist
.cluster
.nodes
.iter()
.max_by_key(|node| format!("{}{}", node.url, node.data_dirs[0]))
.ok_or("configured members")?;
let metadata = std::path::Path::new(&node.data_dirs[0])
.join(bucket)
.join(format!("{prefix}slow/0000/{}/{}", "a".repeat(190), "b".repeat(190)))
.join("xl.meta");
let original = std::fs::read(&metadata)?;
std::fs::write(&metadata, b"corrupt inventory metadata")?;
let before = physical_snapshot(&dist, bucket)?;
let outcome = assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await;
let after = physical_snapshot(&dist, bucket)?;
std::fs::write(&metadata, original)?;
let frames = outcome?;
assert!(before == after, "failed inventory repaired or mutated a physical object");
assert!(
frames
.iter()
.any(|frame| matches!(frame.payload, Payload::Observation { .. })),
"late physical failure must follow real HTTP observations"
);
let client = reqwest::Client::builder()
.no_proxy()
.http2_prior_knowledge()
.http2_initial_stream_window_size(1)
.http2_initial_connection_window_size(1)
.http2_adaptive_window(false)
.build()?;
let url = format!("{}{ROUTE}", dist.cluster.nodes[0].url);
let request = |invocation| {
json!({"version":1,"invocation_id":invocation,"target":target})
.to_string()
.into_bytes()
};
let response = crate::common::signed_request_with_client(
&client,
Method::POST,
&url,
&reader.access,
&reader.secret,
Some(request(Uuid::new_v4())),
Some("application/json"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
// Do not poll the body: its one-byte HTTP/2 receive window stalls transport.
// This waits beyond the fixed30s service budget, not for readiness.
tokio::time::sleep(Duration::from_secs(35)).await;
assert_incomplete(response).await?;
let response = crate::common::signed_request_with_client(
&client,
Method::POST,
&url,
&reader.access,
&reader.secret,
Some(request(Uuid::new_v4())),
Some("application/json"),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
drop(response);
let response = timeout(Duration::from_secs(10), reader.request(&dist, &target, Uuid::new_v4())).await??;
let (frames, clean) = timeout(Duration::from_secs(10), read_frames(response)).await??;
assert!(
clean && matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
"fresh scan must succeed promptly after an actual disconnected HTTP body"
);
Ok(())
}
@@ -1,269 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_repair_tail_test::PhysicalBarrier;
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
use crate::chaos::census_object_version_on_disk;
use crate::common::init_logging;
use crate::storage_api::artifact_rpc::{
DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest,
};
use http::StatusCode;
use rustfs_filemeta::FileMeta;
use rustfs_protos::proto_gen::node_service::{RenameDataRequest, node_service_client::NodeServiceClient};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use uuid::Uuid;
const PUT_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
fn signed_put_request(body: RenameDataRequest, audience: &str) -> TestResult<Request<RenameDataRequest>> {
let canonical = rustfs_protos::canonical_artifact_rename_data_request_body(&body)?;
let mut request = Request::new(body);
request.set_timeout(Duration::from_secs(15));
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let digest = request
.metadata()
.get("x-rustfs-content-sha256")
.ok_or("missing canonical digest")?
.to_str()?
.to_owned();
request.metadata_mut().as_mut().extend(gen_tonic_signature_headers(
audience,
TONIC_RPC_PREFIX.trim_start_matches('/'),
"ArtifactRenameData",
Some(&digest),
)?);
Ok(request)
}
fn publication_snapshot(paths: &[&Path]) -> TestResult<BTreeMap<PathBuf, Option<Vec<u8>>>> {
let mut snapshot = BTreeMap::new();
for root in paths {
for entry in walkdir::WalkDir::new(root) {
let entry = entry?;
let contents = if entry.file_type().is_dir() {
None
} else {
Some(std::fs::read(entry.path())?)
};
snapshot.insert(entry.path().to_path_buf(), contents);
}
}
Ok(snapshot)
}
async fn assert_raw_put_closed(body: &RenameDataRequest, url: &str, audience: &str, paths: &[&Path]) -> TestResult {
let before = publication_snapshot(paths)?;
// Re-sign the exact original request; replay protection is not the rejection.
let request = signed_put_request(body.clone(), audience)?;
let response = timeout(Duration::from_secs(20), async {
let mut client = NodeServiceClient::connect(url.to_owned()).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?.into_inner())
})
.await??;
assert!(!response.success, "sealed attempt admitted another ordinary PUT");
let error = response.error.ok_or("rejected raw PUT omitted error")?;
assert_eq!(error.code, DiskError::Io(std::io::ErrorKind::PermissionDenied.into()).to_u32());
assert_eq!(error.error_info, "io error artifact attempt mutation domain is closed");
assert_eq!(publication_snapshot(paths)?, before, "rejected raw PUT mutated staged or published files");
Ok(())
}
#[tokio::test]
async fn four_node_artifact_put_seal_waits_for_disconnected_physical_tail() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let key = format!("{prefix}detached-put.bin");
let payload = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(payload.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
let request_body = json!({"version": 1, "target": target}).to_string();
let baselines = dist
.cluster
.nodes
.iter()
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, &key, Some(version)))
.collect::<TestResult<Vec<_>>>()?;
assert!(
baselines
.iter()
.all(|baseline| baseline.is_complete() && !baseline.expected_part_numbers.is_empty())
);
let node = &dist.cluster.nodes[3];
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
let object = disk.join(bucket).join(&key);
let metadata_bytes = std::fs::read(object.join("xl.meta"))?;
let fi = FileMeta::load(&metadata_bytes)?.into_fileinfo(bucket, &key, version, true, false, true)?;
let admitted_file_info = serde_json::to_value(&fi)?;
assert!(!fi.is_healing(), "ordinary publication must not carry the repair marker");
let data_dir = fi
.data_dir
.ok_or("physical-tail fixture requires external shards")?
.to_string();
let source = Uuid::new_v4().to_string();
let staging = disk.join(".rustfs.sys/tmp").join(&source);
std::fs::create_dir_all(&staging)?;
let destination = object.join(&data_dir);
// Replay the receiver publication of a real ordinary PUT while its attempt
// remains open. Preserve its referenced version metadata. The admitted
// owned operation may still finish metadata publication after RPC detach.
std::fs::rename(&destination, staging.join(&data_dir))?;
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
let put = RenameDataRequest {
disk: disk_id.to_string(),
src_volume: ".rustfs.sys/tmp".to_owned(),
src_path: source,
dst_volume: bucket.to_owned(),
dst_path: key.clone(),
file_info: serde_json::to_string(&fi)?,
file_info_bin: Default::default(),
scanner_publication_lease_token: Default::default(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
};
let request = signed_put_request(put.clone(), &node.address)?;
// Direct RPC takes no coordinator attempt lock and owns its own connection.
let url = node.url.clone();
let mut rpc = tokio::spawn(async move {
timeout(Duration::from_secs(30), async move {
let mut client = NodeServiceClient::connect(url).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?)
})
.await
});
tokio::select! {
result = &mut rpc => {
let response = result???.into_inner();
panic!(
"ordinary PUT receiver completed before the physical barrier: success={}, error={:?}",
response.success, response.error
);
}
arrived = barrier.observed("arrived") => arrived?,
}
assert!(!destination.exists(), "physical PUT escaped its barrier");
rpc.abort();
assert!(rpc.await.expect_err("disconnect the ordinary PUT waiter").is_cancelled());
// The ordinary PUT wrapper retains an owned task. Observe its RPC-facing
// waiter dropping, rather than cancellation of the continuing disk task.
barrier.observed("rpc-waiter-dropped").await?;
let seal = user.request(&dist, 0, PUT_SEAL_ROUTE, request_body.clone());
tokio::pin!(seal);
let closed = wait_until(
Duration::from_secs(15),
|| async {
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-put-seals"))
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.any(|entry| entry.file_name() == "closed"))
},
"PUT receiver persisted closing before physical drain",
);
tokio::pin!(closed);
tokio::select! {
result = &mut seal => {
let (status, response) = result?;
panic!("PUT seal returned before its blocked receiver closed admission: {status} {response}");
}
result = &mut closed => result?,
}
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"PUT seal confirmed an unfinished physical tail: {response}"
);
assert!(response.contains("artifact operation unconfirmed"));
assert!(!destination.exists());
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
assert!(!destination.exists(), "a second admission published while the worker was paused");
barrier.release()?;
wait_until(
Duration::from_secs(15),
|| async { Ok(destination.join("part.1").is_file()) },
"detached ordinary PUT worker finished",
)
.await?;
confirm_seal(&dist, &user, &request_body, &target).await?;
// Seal confirmation is the boundary: already admitted work may re-encode
// metadata before this point, but the complete FileInfo must be unchanged.
let published_objects: Vec<_> = dist
.cluster
.nodes
.iter()
.map(|node| PathBuf::from(&node.data_dirs[0]).join(bucket).join(&key))
.collect();
let published_paths: Vec<_> = published_objects.iter().map(PathBuf::as_path).collect();
let sealed_snapshot = publication_snapshot(&published_paths)?;
let sealed_file_info =
FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?.into_fileinfo(bucket, &key, version, true, false, true)?;
assert_eq!(
serde_json::to_value(sealed_file_info)?,
admitted_file_info,
"admitted PUT changed FileInfo semantics"
);
for (index, baseline) in baselines.iter().enumerate() {
assert!(
census_object_version_on_disk(Path::new(&dist.cluster.nodes[index].data_dirs[0]), bucket, &key, Some(version))?
.matches_manifest(baseline)
);
assert_exact_version(&dist.client(index)?, bucket, &key, version, &payload).await?;
}
// Stage the exact request again. A source metadata sentinel makes a missing
// admission check observable even if the existing destination later causes
// ENOTEMPTY: ordinary rename rewrites source metadata before that syscall.
let staged_data = staging.join(&data_dir);
std::fs::create_dir_all(&staged_data)?;
for entry in std::fs::read_dir(&destination)? {
let entry = entry?;
assert!(entry.file_type()?.is_file(), "unexpected nested shard fixture");
std::fs::copy(entry.path(), staged_data.join(entry.file_name()))?;
}
std::fs::write(staging.join("xl.meta"), b"closed-admission-must-preserve-this-staged-metadata")?;
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(&key)
.send()
.await?;
assert_eq!(listed.versions().len(), 1, "raw PUT retries created another version");
assert_eq!(listed.versions()[0].version_id(), Some(version));
assert!(listed.delete_markers().is_empty());
confirm_seal(&dist, &user, &request_body, &target).await?;
assert!(
publication_snapshot(&published_paths)? == sealed_snapshot,
"published files changed after the first successful physical-drain confirmation"
);
Ok(())
}
@@ -1,218 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
use crate::chaos::census_object_version_on_disk;
use crate::common::init_logging;
use crate::storage_api::artifact_rpc::{TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
use http::StatusCode;
use rustfs_filemeta::FileMeta;
use rustfs_protos::proto_gen::node_service::{
ArtifactRepairRenameDataRequest, RenameDataRequest, node_service_client::NodeServiceClient,
};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
use tonic::Request;
use uuid::Uuid;
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
pub(super) struct PhysicalBarrier(PathBuf);
impl PhysicalBarrier {
pub(super) fn arm(disk: &Path, destination: &Path) -> TestResult<Self> {
let path = disk.join(".rustfs.sys/e2e-physical-rename-barrier");
std::fs::write(&path, destination.to_str().ok_or("non-UTF8 fixture destination")?)?;
Ok(Self(path))
}
pub(super) async fn observed(&self, stage: &str) -> TestResult {
wait_until(Duration::from_secs(15), || async { Ok(self.0.with_extension(stage).is_file()) }, stage).await
}
pub(super) fn release(&self) -> TestResult {
std::fs::write(self.0.with_extension("release"), b"release physical worker")?;
Ok(())
}
}
impl Drop for PhysicalBarrier {
fn drop(&mut self) {
// Release even on assertion failure; the worker also has its own deadline.
let _ = self.release();
let _ = std::fs::remove_file(&self.0);
}
}
#[tokio::test]
async fn four_node_artifact_repair_seal_waits_for_disconnected_physical_tail() -> TestResult {
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
init_logging();
// Match the other raw internode signature tests sharing the OnceLock.
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let key = format!("{prefix}detached-rename.bin");
let body = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(body.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
let request_body = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request_body, &target).await?;
let node = &dist.cluster.nodes[3];
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
let object = disk.join(bucket).join(&key);
let metadata = FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?;
let mut fi = metadata.into_fileinfo(bucket, &key, version, true, false, true)?;
fi.set_healing();
let data_dir = fi
.data_dir
.ok_or("physical-tail fixture requires external shard files")?
.to_string();
let baseline = census_object_version_on_disk(&disk, bucket, &key, Some(version))?;
assert!(baseline.is_complete() && !baseline.expected_part_numbers.is_empty());
let source = Uuid::new_v4().to_string();
let staging = disk.join(".rustfs.sys/tmp").join(&source);
std::fs::create_dir_all(&staging)?;
let destination = object.join(&data_dir);
std::fs::rename(&destination, staging.join(&data_dir))?;
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
let repair = ArtifactRepairRenameDataRequest {
rename: Some(RenameDataRequest {
disk: disk_id.to_string(),
src_volume: ".rustfs.sys/tmp".to_owned(),
src_path: source,
dst_volume: bucket.to_owned(),
dst_path: key.clone(),
file_info: serde_json::to_string(&fi)?,
file_info_bin: Default::default(),
scanner_publication_lease_token: Default::default(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: disk_id.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_rename_data_request_body(&repair)?;
let mut request = Request::new(repair);
request.set_timeout(Duration::from_secs(15));
set_tonic_canonical_body_digest(&mut request, &canonical)?;
let digest = request
.metadata()
.get("x-rustfs-content-sha256")
.ok_or("missing canonical digest")?
.to_str()?
.to_owned();
let headers = gen_tonic_signature_headers(
&node.address,
TONIC_RPC_PREFIX.trim_start_matches('/'),
"ArtifactRepairRenameData",
Some(&digest),
)?;
request.metadata_mut().as_mut().extend(headers);
// An independent channel and direct receiver RPC take no coordinator attempt
// lock. Dropping this task closes the client stream while rename is paused.
let url = node.url.clone();
let mut rpc = tokio::spawn(async move {
timeout(Duration::from_secs(30), async move {
let mut client = NodeServiceClient::connect(url).await?;
client
.artifact_repair_rename_data(request)
.await
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })
})
.await
});
tokio::select! {
result = &mut rpc => {
let response = result???.into_inner();
panic!(
"repair receiver completed before the physical barrier: success={}, error={:?}",
response.success, response.error
);
}
arrived = barrier.observed("arrived") => arrived?,
}
assert!(!destination.exists(), "physical publication ran before its barrier");
rpc.abort();
assert!(rpc.await.expect_err("cancel the disconnected RPC waiter").is_cancelled());
// This acknowledgement is emitted by the receiver's dropped async waiter,
// not by the client and not by a delayed response.
barrier.observed("waiter-dropped").await?;
let seal = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone());
tokio::pin!(seal);
let closed = wait_until(
Duration::from_secs(15),
|| async {
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-repair-seals"))
.min_depth(2)
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.any(|entry| entry.file_name() == "closed"))
},
"receiver persisted repair closure while its physical worker remains paused",
);
tokio::pin!(closed);
tokio::select! {
result = &mut seal => {
let (status, response) = result?;
panic!("repair seal returned before the blocked receiver closed admission: {status} {response}");
}
result = &mut closed => result?,
}
// Cover a complete receiver drain timeout, so early physical-owner release
// causes a successful response here and fails this runtime assertion.
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
assert_eq!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"seal confirmed an unfinished physical tail: {response}"
);
assert!(response.contains("artifact operation unconfirmed"));
assert!(!destination.exists(), "worker escaped the physical barrier");
barrier.release()?;
wait_until(
Duration::from_secs(15),
|| async { Ok(destination.join("part.1").is_file()) },
"detached physical rename completes after release",
)
.await?;
wait_until(
Duration::from_secs(45),
|| async {
let (status, response) = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone()).await?;
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed") {
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "repair seal did not confirm after physical drain: {response}");
assert_eq!(
serde_json::from_str::<Value>(&response)?,
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
);
Ok(true)
},
"exact retry confirms all four receivers after physical drain",
)
.await?;
assert!(census_object_version_on_disk(&disk, bucket, &key, Some(version))?.matches_manifest(&baseline));
assert_exact_version(&dist.client(0)?, bucket, &key, version, &body).await?;
Ok(())
}
@@ -1,327 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, unique_bucket, wait_for_ready, wait_until,
};
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post, start_root_heal_when_control_ready};
use crate::common::init_logging;
use http::StatusCode;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::timeout;
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
const HEAL_BODY: &str =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
const HEAL_BUDGET: Duration = Duration::from_secs(120);
pub(super) struct RepairObject {
pub(super) key: String,
pub(super) version: String,
pub(super) body: Vec<u8>,
manifests: Vec<VersionShardCensus>,
}
fn roots(dist: &DistCluster) -> Vec<PathBuf> {
dist.cluster
.nodes
.iter()
.map(|node| PathBuf::from(&node.data_dirs[0]))
.collect()
}
impl RepairObject {
pub(super) async fn put(dist: &DistCluster, bucket: &str, key: String) -> TestResult<Self> {
let body = payload_for(&key, 2 * 1024 * 1024);
let output = timeout(
Duration::from_secs(30),
dist.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(body.clone().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("managed PUT omitted version ID")?.to_owned();
let manifests = roots(dist)
.iter()
.map(|disk| census_object_version_on_disk(disk, bucket, &key, Some(&version)))
.collect::<TestResult<Vec<_>>>()?;
for manifest in &manifests {
assert!(manifest.is_complete(), "repair needs a complete baseline: {manifest:?}");
assert!(!manifest.expected_part_numbers.is_empty(), "repair must use actual shard files");
}
Ok(Self {
key,
version,
body,
manifests,
})
}
pub(super) fn part(&self, disk: &Path, bucket: &str, node: usize) -> TestResult<PathBuf> {
let data_dir = self.manifests[node]
.data_dir
.as_ref()
.ok_or("external shard omitted data directory")?;
Ok(disk.join(bucket).join(&self.key).join(data_dir).join("part.1"))
}
pub(super) async fn assert_repaired(&self, dist: &DistCluster, bucket: &str) -> TestResult {
wait_until(
HEAL_BUDGET,
|| async {
for (disk, baseline) in roots(dist).iter().zip(&self.manifests) {
if !census_object_version_on_disk(disk, bucket, &self.key, Some(&self.version))?.matches_manifest(baseline) {
return Ok(false);
}
}
Ok(true)
},
"every configured receiver restored the exact version and original shard fingerprints",
)
.await?;
for node in 0..dist.cluster.nodes.len() {
assert_exact_version(&dist.client(node)?, bucket, &self.key, &self.version, &self.body).await?;
}
Ok(())
}
}
pub(super) async fn start_heal(dist: &DistCluster, bucket: &str, prefix: &str, remove: bool) -> TestResult<String> {
let url = format!("{}/rustfs/admin/v3/heal/{bucket}/{prefix}?forceStart=true", dist.cluster.nodes[0].url);
let body = if remove {
HEAL_BODY.replace("\"remove\":false", "\"remove\":true")
} else {
HEAL_BODY.to_owned()
};
let response = timeout(
HEAL_BUDGET,
start_root_heal_when_control_ready(&url, &body, &dist.cluster.access_key, &dist.cluster.secret_key),
)
.await??;
let response: Value = serde_json::from_str(&response)?;
Ok(response["clientToken"]
.as_str()
.ok_or("heal omitted its task token")?
.to_owned())
}
pub(super) async fn finish_heal(dist: &DistCluster, token: &str, rejected_key: Option<&str>) -> TestResult {
let url = format!("{}/rustfs/admin/v3/heal/?clientToken={token}", dist.cluster.nodes[0].url);
wait_until(
HEAL_BUDGET,
|| async {
let response = signed_admin_post(&url, None, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
let status: Value = serde_json::from_str(&response)?;
if status["summary"] == "running" {
return Ok(false);
}
if let Some(key) = rejected_key {
assert_eq!(status["summary"], "stopped", "closed repair must report failure: {status}");
assert!(
status["outcome"]["counters"]["failed"]
.as_u64()
.is_some_and(|count| count > 0)
);
assert!(
status["outcome"]["objects"].as_array().is_some_and(|objects| objects
.iter()
.any(|object| { object["identity"]["object"] == key && object["disposition"]["state"] == "failed" })),
"heal must actually attempt the rejected object: {status}"
);
} else {
assert_eq!(status["summary"], "finished", "heal did not complete: {status}");
}
Ok(true)
},
"exact admin heal task reached its expected terminal outcome",
)
.await
}
async fn repair_seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
wait_until(
Duration::from_secs(45),
|| async {
let request = json!({"version": 1, "target": target}).to_string();
let (status, body) = user.request(dist, 0, REPAIR_SEAL_ROUTE, request).await?;
if !confirmed {
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver repair seal: {body}");
assert!(body.contains("<Code>ServiceUnavailable</Code>"));
assert!(body.contains("artifact operation unconfirmed"));
return Ok(true);
}
if status == StatusCode::SERVICE_UNAVAILABLE
&& body.contains("<Code>ServiceUnavailable</Code>")
&& body.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "repair seal failed: {body}");
assert_eq!(
serde_json::from_str::<Value>(&body)?,
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
);
Ok(true)
},
"repair seal confirms every configured receiver",
)
.await
}
#[tokio::test]
async fn four_node_artifact_shard_and_minority_metadata_repair_after_put_seal() -> TestResult {
init_logging();
// Sub-quorum metadata recovery supports genuine legacy shards only.
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
let later = dist
.client(2)?
.put_object()
.bucket(bucket)
.key(&shard.key)
.body(b"newer-version".to_vec().into())
.send()
.await?;
let later_version = later.version_id().ok_or("second PUT omitted version ID")?.to_owned();
assert_ne!(shard.version, later_version);
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
dist.cluster.stop();
let disks = roots(&dist);
std::fs::remove_file(shard.part(&disks[3], bucket, 3)?)?;
for disk in &disks[1..] {
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
}
assert!(!census_object_version_on_disk(&disks[3], bucket, &shard.key, Some(&shard.version))?.is_complete());
assert!(disks[0].join(bucket).join(&metadata.key).join("xl.meta").is_file());
assert!(
disks[1..]
.iter()
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
);
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let token = start_heal(&dist, bucket, prefix, false).await?;
shard.assert_repaired(&dist, bucket).await?;
metadata.assert_repaired(&dist, bucket).await?;
finish_heal(&dist, &token, None).await?;
assert_exact_version(&dist.client(3)?, bucket, &shard.key, &later_version, b"newer-version").await?;
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(prefix)
.send()
.await?;
assert_eq!(listed.versions().len(), 3, "repair must not publish replacement versions");
assert!(listed.delete_markers().is_empty());
Ok(())
}
#[tokio::test]
async fn four_node_artifact_repair_seal_denies_shard_and_metadata_repair_after_restart() -> TestResult {
init_logging();
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
repair_seal(&dist, &user, &target, true).await?;
let ordinary_bucket = unique_bucket("repair-control");
dist.create_bucket(&ordinary_bucket).await?;
enable_versioning(&dist.client(0)?, &ordinary_bucket).await?;
let ordinary = RepairObject::put(&dist, &ordinary_bucket, "ordinary.bin".to_owned()).await?;
let disks = roots(&dist);
let part = shard.part(&disks[3], bucket, 3)?;
std::fs::remove_file(&part)?;
for disk in &disks[1..] {
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
}
for restarted in [false, true] {
if restarted {
dist.restart_with_current_binary().await?;
}
// The same receivers must still execute an ordinary bucket repair.
std::fs::remove_file(ordinary.part(&disks[3], &ordinary_bucket, 3)?)?;
let control = start_heal(&dist, &ordinary_bucket, &ordinary.key, false).await?;
ordinary.assert_repaired(&dist, &ordinary_bucket).await?;
finish_heal(&dist, &control, None).await?;
for object in [&shard, &metadata] {
let token = start_heal(&dist, bucket, &object.key, false).await?;
finish_heal(&dist, &token, Some(&object.key)).await?;
}
assert!(!part.exists(), "sealed repair recreated the missing shard");
assert!(
disks[1..]
.iter()
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
);
assert_exact_version(&dist.client(2)?, bucket, &shard.key, &shard.version, &shard.body).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_repair_seal_requires_every_receiver_on_exact_retry() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let request = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request, &target).await?;
// Repeat after global confirmation: its persisted receipt cannot replace a
// fresh acknowledgement from an offline configured receiver.
for _ in 0..2 {
dist.cluster.stop_node(3)?;
repair_seal(&dist, &user, &target, false).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
repair_seal(&dist, &user, &target, true).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_dangling_repair_preserves_surviving_metadata() -> TestResult {
init_logging();
let mut dist =
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
let object = RepairObject::put(&dist, bucket, format!("{prefix}dangling.bin")).await?;
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
dist.cluster.stop();
let disks = roots(&dist);
let survivor = disks[0].join(bucket).join(&object.key).join("xl.meta");
let before = std::fs::read(&survivor)?;
for (node, disk) in disks.iter().enumerate() {
std::fs::remove_file(object.part(disk, bucket, node)?)?;
if node != 0 {
std::fs::remove_file(disk.join(bucket).join(&object.key).join("xl.meta"))?;
}
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let token = start_heal(&dist, bucket, &object.key, true).await?;
finish_heal(&dist, &token, Some(&object.key)).await?;
assert_eq!(
std::fs::read(survivor)?,
before,
"distributed repair must not authorize managed dangling deletion"
);
Ok(())
}
@@ -1,347 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, put_object, s3_probe_client, unique_bucket, wait_for_ready,
wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, admin_request, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use http::{Method, StatusCode};
use serde_json::{Value, json};
use std::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
const BUCKET_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets";
const INSPECT_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets/inspect";
const SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
const ADMIN_BUDGET: Duration = Duration::from_secs(45);
pub(super) struct ArtifactAdmin {
access_key: String,
secret_key: String,
}
impl ArtifactAdmin {
pub(super) async fn request(
&self,
dist: &DistCluster,
node: usize,
route: &str,
body: String,
) -> TestResult<(StatusCode, String)> {
timeout(
ADMIN_BUDGET,
admin_request(
&dist.cluster.nodes[node].url,
Method::POST,
route,
Some(body),
&self.access_key,
&self.secret_key,
),
)
.await?
}
}
async fn create_artifact_admin(dist: &DistCluster, bucket: &str, prefix: &str) -> TestResult<ArtifactAdmin> {
let user = ArtifactAdmin {
access_key: format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]),
secret_key: Uuid::new_v4().simple().to_string(),
};
let policy_name = format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]);
let policy = json!({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["admin:CreatePluginArtifactBucket", "admin:GetPluginArtifactBucket"],
"Resource": format!("arn:aws:s3:::{bucket}"),
},
{
"Effect": "Allow",
"Action": ["admin:SealPluginArtifactAttempt", "admin:SealPluginArtifactRepairs", "admin:SealPluginArtifactWrites"],
"Resource": format!("arn:aws:s3:::{bucket}/{}", prefix.trim_end_matches('/')),
},
],
})
.to_string();
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, secret, &user.access_key, &user.secret_key).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &user.access_key).await?;
Ok(user)
}
pub(super) async fn create_artifact_attempt(dist: &DistCluster) -> TestResult<(Value, ArtifactAdmin)> {
let bucket = unique_bucket("artifact");
let prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
let user = create_artifact_admin(dist, &bucket, &prefix).await?;
let body = json!({"version": 1, "bucket": bucket, "base_prefix": "outputs/"}).to_string();
let (status, response) = user.request(dist, 0, BUCKET_ROUTE, body).await?;
assert_eq!(status, StatusCode::OK, "artifact bucket creation failed: {response}");
let receipt: Value = serde_json::from_str(&response)?;
assert_eq!(receipt["version"], 1);
assert_eq!(receipt["bucket"], bucket);
assert_eq!(receipt["base_prefix"], "outputs/");
assert_eq!(receipt["versioning_enabled"], true);
let incarnation = receipt["bucket_incarnation"]
.as_str()
.ok_or("artifact receipt omitted incarnation")?;
assert!(!Uuid::parse_str(incarnation)?.is_nil());
let target = json!({
"bucket": bucket,
"bucket_incarnation": incarnation,
"attempt_prefix": prefix,
});
assert_bucket_identity(dist, &user, 1, &target).await?;
Ok((target, user))
}
async fn assert_bucket_identity(dist: &DistCluster, user: &ArtifactAdmin, node: usize, target: &Value) -> TestResult {
let body = json!({"version": 1, "bucket": target["bucket"], "base_prefix": "outputs/"}).to_string();
wait_until(
ADMIN_BUDGET,
|| async {
let (status, response) = user.request(dist, node, INSPECT_ROUTE, body.clone()).await?;
// IAM peers may not have received the new user and policy yet.
// Storage failures must fail immediately, including the old gate's 503.
if matches!(status, StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED)
&& (response.contains("<Code>AccessDenied</Code>") || response.contains("<Code>InvalidAccessKeyId</Code>"))
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "cross-node artifact inspect failed: {response}");
assert_eq!(
serde_json::from_str::<Value>(&response)?,
json!({
"version": 1,
"bucket": target["bucket"],
"base_prefix": "outputs/",
"bucket_incarnation": target["bucket_incarnation"],
"versioning_enabled": true,
}),
"inspect must return the created managed bucket identity"
);
Ok(true)
},
"artifact IAM user and policy propagation",
)
.await
}
pub(super) async fn confirm_seal(dist: &DistCluster, user: &ArtifactAdmin, request: &str, target: &Value) -> TestResult {
wait_until(
ADMIN_BUDGET,
|| async {
let (status, response) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
// HTTP readiness does not confirm that remote disk handles have
// recovered. Retry only the API's explicit same-identity response.
if status == StatusCode::SERVICE_UNAVAILABLE
&& response.contains("<Code>ServiceUnavailable</Code>")
&& response.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
{
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "artifact seal failed: {response}");
let receipt: Value = serde_json::from_str(&response)?;
assert_eq!(receipt, json!({"version": 1, "target": target, "ordinary_put_sealed": true}));
Ok(true)
},
"exact artifact seal confirmation after receiver recovery",
)
.await
}
async fn assert_seal_unconfirmed(dist: &DistCluster, user: &ArtifactAdmin, request: &str) -> TestResult {
let (status, body) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver seal response: {body}");
assert!(
body.contains("<Code>ServiceUnavailable</Code>"),
"seal failure lost its service code: {body}"
);
assert!(
body.contains("artifact operation unconfirmed"),
"seal failure lost its retry contract: {body}"
);
Ok(())
}
pub(super) async fn assert_exact_version(client: &Client, bucket: &str, key: &str, version: &str, body: &[u8]) -> TestResult {
let output = timeout(
Duration::from_secs(30),
client.get_object().bucket(bucket).key(key).version_id(version).send(),
)
.await??;
assert_eq!(output.version_id(), Some(version));
let actual = timeout(Duration::from_secs(30), output.body.collect()).await??.into_bytes();
assert_eq!(actual.as_ref(), body);
Ok(())
}
async fn assert_put_denied(client: &Client, ordinary_bucket: &str, bucket: &str, key: &str) -> TestResult {
// The same receiver must still accept a normal write before its sealed probe.
put_object(client, ordinary_bucket, key, b"ordinary-control".to_vec()).await?;
assert_object_bytes(client, ordinary_bucket, key, b"ordinary-control").await?;
let client = s3_probe_client(client);
let result = timeout(
Duration::from_secs(30),
client
.put_object()
.bucket(bucket)
.key(key)
.body(b"must-not-publish".to_vec().into())
.send(),
)
.await?;
let error = result.expect_err("sealed artifact attempt accepted a new PUT");
let service = error
.as_service_error()
.ok_or("sealed PUT failed without a service rejection")?;
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(500),
"sealed PUT must return its current HTTP status"
);
assert_eq!(service.code(), Some("InternalError"), "sealed PUT must return its current service code");
// StorageError::Io is redacted at the API boundary; the sealed reason is
// not exposed. Success controls and exact inventory checks prove the effect.
assert_eq!(
service.message(),
Some("We encountered an internal error, please try again."),
"sealed PUT must retain the public redacted message"
);
Ok(())
}
#[tokio::test]
async fn four_node_artifact_put_seal_survives_full_restart() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
let key = format!("{prefix}result.bin");
let mut versions = Vec::new();
for (node, body) in [(1, b"first-version".as_slice()), (2, b"second-version".as_slice())] {
let client = dist.client(node)?;
let output = timeout(
Duration::from_secs(30),
client.put_object().bucket(bucket).key(&key).body(body.to_vec().into()).send(),
)
.await??;
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
assert!(!Uuid::parse_str(&version)?.is_nil());
assert_exact_version(&dist.client(3)?, bucket, &key, &version, body).await?;
versions.push((version, body.to_vec()));
}
assert_ne!(versions[0].0, versions[1].0);
let ordinary = unique_bucket("ordinary");
dist.create_bucket(&ordinary).await?;
put_object(&dist.client(2)?, &ordinary, "normal.bin", b"before-seal".to_vec()).await?;
assert_object_bytes(&dist.client(3)?, &ordinary, "normal.bin", b"before-seal").await?;
let request = json!({"version": 1, "target": target}).to_string();
confirm_seal(&dist, &user, &request, &target).await?;
let other_prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
for phase in ["sealed", "restarted"] {
if phase == "restarted" {
dist.cluster.stop();
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
}
assert_bucket_identity(&dist, &user, 2, &target).await?;
for node in 0..dist.cluster.nodes.len() {
// A second attempt in the managed bucket must remain writable;
// a broken bucket-wide write path cannot stand in for sealing.
let client = dist.client(node)?;
let other_key = format!("{other_prefix}{phase}-{node}.bin");
let output = timeout(
Duration::from_secs(30),
client
.put_object()
.bucket(bucket)
.key(&other_key)
.body(b"other-attempt".to_vec().into())
.send(),
)
.await??;
let version = output.version_id().ok_or("other attempt PUT omitted version ID")?;
assert!(!Uuid::parse_str(version)?.is_nil());
assert_exact_version(
&dist.client((node + 1) % dist.cluster.nodes.len())?,
bucket,
&other_key,
version,
b"other-attempt",
)
.await?;
assert_put_denied(&dist.client(node)?, &ordinary, bucket, &format!("{prefix}{phase}-{node}.bin")).await?;
for (version, body) in &versions {
assert_exact_version(&dist.client(node)?, bucket, &key, version, body).await?;
}
}
let listed = dist
.client(0)?
.list_object_versions()
.bucket(bucket)
.prefix(prefix)
.send()
.await?;
assert_eq!(listed.versions().len(), 2, "denied PUT left an artifact version: {listed:?}");
assert!(listed.delete_markers().is_empty(), "artifact seal created a delete marker");
put_object(&dist.client(1)?, &ordinary, "normal.bin", phase.as_bytes().to_vec()).await?;
assert_object_bytes(&dist.client(2)?, &ordinary, "normal.bin", phase.as_bytes()).await?;
}
Ok(())
}
#[tokio::test]
async fn four_node_artifact_seal_requires_every_configured_receiver_on_exact_retry() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let (target, user) = create_artifact_attempt(&dist).await?;
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
let key = format!("{prefix}existing.bin");
let output = dist
.client(1)?
.put_object()
.bucket(bucket)
.key(&key)
.body(b"existing".to_vec().into())
.send()
.await?;
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
assert_exact_version(&dist.client(3)?, bucket, &key, &version, b"existing").await?;
let ordinary = unique_bucket("ordinary");
dist.create_bucket(&ordinary).await?;
let request = json!({"version": 1, "target": target}).to_string();
// The second iteration starts from a fully acknowledged global seal. Its
// persisted record cannot replace a fresh acknowledgement from every disk.
for _ in 0..2 {
dist.cluster.stop_node(3)?;
assert_seal_unconfirmed(&dist, &user, &request).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
confirm_seal(&dist, &user, &request, &target).await?;
}
assert_put_denied(&dist.client(3)?, &ordinary, bucket, &format!("{prefix}late.bin")).await?;
assert_exact_version(&dist.client(2)?, bucket, &key, &version, b"existing").await?;
let listed = timeout(
Duration::from_secs(30),
dist.client(0)?.list_object_versions().bucket(bucket).prefix(prefix).send(),
)
.await??;
assert_eq!(listed.versions().len(), 1, "rejected PUT added a version");
assert!(listed.delete_markers().is_empty(), "seal added a delete marker");
Ok(())
}
@@ -1,235 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
#![cfg(unix)]
use super::artifact_delete_test::physical_snapshot;
use super::artifact_repair_test::{RepairObject, finish_heal, start_heal};
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, s3_probe_client, unique_bucket, wait_for_ready, wait_until,
};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
ObjectLockRetentionMode,
};
use http::StatusCode;
use serde_json::{Value, json};
use std::path::Path;
use std::time::Duration;
const WRITES_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-writes";
const INTERNAL_MESSAGE: &str = "We encountered an internal error, please try again.";
async fn seal_writes(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
wait_until(Duration::from_secs(45), || async {
let (status, response) = user.request(dist, 0, WRITES_ROUTE, json!({"version":1,"target":target}).to_string()).await?;
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed; retry the same identity") {
return Ok(!confirmed);
}
assert!(confirmed, "offline configured receiver produced a complete receipt: {status} {response}");
assert_eq!(status, StatusCode::OK, "combined writes seal: {response}");
assert_eq!(serde_json::from_str::<Value>(&response)?, json!({"version":1,"target":target,"ordinary_put_sealed":true,"attempt_repairs_sealed":true,"all_writes_sealed":true}));
Ok(true)
}, "combined seal acknowledges both domains on every receiver").await
}
async fn assert_put_closed(dist: &DistCluster, bucket: &str, key: &str) -> TestResult {
let before = physical_snapshot(dist, bucket)?;
let client = s3_probe_client(&dist.client(2)?);
let error = client
.put_object()
.bucket(bucket)
.key(key)
.body(b"must-not-publish".to_vec().into())
.send()
.await
.expect_err("combined seal admitted a new ordinary PUT");
let service = error.as_service_error().ok_or("PUT was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
assert!(
physical_snapshot(dist, bucket)? == before,
"rejected PUT changed the managed physical namespace"
);
Ok(())
}
async fn lock_metadata_entrypoints(dist: &DistCluster, managed: &Value, object: &RepairObject, ordinary: &str) -> TestResult {
let bucket = managed["bucket"].as_str().ok_or("bucket identity")?;
let client = s3_probe_client(&dist.client(0)?);
for bucket in [bucket, ordinary] {
client
.put_object_lock_configuration()
.bucket(bucket)
.object_lock_configuration(
ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.build(),
)
.send()
.await?;
wait_until(
Duration::from_secs(30),
|| async {
for node in 0..4 {
let output = match dist.client(node)?.get_object_lock_configuration().bucket(bucket).send().await {
Ok(output) => output,
Err(error)
if error.as_service_error().and_then(ProvideErrorMetadata::code)
== Some("ObjectLockConfigurationNotFoundError") =>
{
return Ok(false);
}
Err(error) => return Err(error.into()),
};
if output
.object_lock_configuration()
.and_then(|configuration| configuration.object_lock_enabled())
!= Some(&ObjectLockEnabled::Enabled)
{
return Ok(false);
}
}
Ok(true)
},
"actual Object Lock configuration is enabled on all request nodes",
)
.await?;
}
let control = RepairObject::put(dist, ordinary, "lock-control.bin".to_owned()).await?;
let retention = ObjectLockRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.retain_until_date(aws_sdk_s3::primitives::DateTime::from_secs(chrono::Utc::now().timestamp() + 86400))
.build();
let hold = ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build();
client
.put_object_retention()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.retention(retention.clone())
.send()
.await?;
assert_eq!(
client
.get_object_retention()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.send()
.await?
.retention()
.and_then(|value| value.mode()),
Some(&ObjectLockRetentionMode::Governance)
);
client
.put_object_legal_hold()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.legal_hold(hold.clone())
.send()
.await?;
assert_eq!(
client
.get_object_legal_hold()
.bucket(ordinary)
.key(&control.key)
.version_id(&control.version)
.send()
.await?
.legal_hold()
.and_then(|value| value.status()),
Some(&ObjectLockLegalHoldStatus::On)
);
let before = physical_snapshot(dist, bucket)?;
let error = client
.put_object_retention()
.bucket(bucket)
.key(&object.key)
.version_id(&object.version)
.retention(retention)
.send()
.await
.expect_err("managed Retention must reach and fail its unsupported metadata write");
let service = error.as_service_error().ok_or("retention was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
assert!(physical_snapshot(dist, bucket)? == before, "retention changed managed metadata");
let error = client
.put_object_legal_hold()
.bucket(bucket)
.key(&object.key)
.version_id(&object.version)
.legal_hold(hold)
.send()
.await
.expect_err("managed LegalHold must reach and fail its unsupported metadata write");
let service = error.as_service_error().ok_or("legal hold was not a service rejection")?;
assert_eq!(service.code(), Some("InternalError"));
assert_eq!(service.message(), Some("Io error: artifact bucket does not support this write operation"));
assert!(physical_snapshot(dist, bucket)? == before, "legal hold changed managed metadata");
Ok(())
}
#[tokio::test]
async fn four_node_artifact_writes_seal_first_partial_retry_offline_and_restart() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
let ordinary = unique_bucket("writes-control");
dist.create_bucket(&ordinary).await?;
enable_versioning(&dist.client(0)?, &ordinary).await?;
let (fresh, fresh_user) = create_artifact_attempt(&dist).await?;
let fresh_bucket = fresh["bucket"].as_str().ok_or("fresh bucket")?;
let fresh_prefix = fresh["attempt_prefix"].as_str().ok_or("fresh prefix")?;
let first = RepairObject::put(&dist, fresh_bucket, format!("{fresh_prefix}existing.bin")).await?;
lock_metadata_entrypoints(&dist, &fresh, &first, &ordinary).await?;
seal_writes(&dist, &fresh_user, &fresh, true).await?;
seal_writes(&dist, &fresh_user, &fresh, true).await?;
assert_put_closed(&dist, fresh_bucket, &format!("{fresh_prefix}closed.bin")).await?;
let (partial, partial_user) = create_artifact_attempt(&dist).await?;
let bucket = partial["bucket"].as_str().ok_or("partial bucket")?;
let prefix = partial["attempt_prefix"].as_str().ok_or("partial prefix")?;
let repaired = RepairObject::put(&dist, bucket, format!("{prefix}repair.bin")).await?;
// Enter the combined operation with only its ordinary domain closed.
confirm_seal(&dist, &partial_user, &json!({"version":1,"target":partial}).to_string(), &partial).await?;
let part = repaired.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), bucket, 3)?;
std::fs::remove_file(&part)?;
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
repaired.assert_repaired(&dist, bucket).await?;
finish_heal(&dist, &token, None).await?;
for _ in 0..2 {
dist.cluster.stop_node(3)?;
seal_writes(&dist, &partial_user, &partial, false).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
seal_writes(&dist, &partial_user, &partial, true).await?;
}
let ordinary_object = RepairObject::put(&dist, &ordinary, "repair-control.bin".to_owned()).await?;
for restarted in [false, true] {
if restarted {
dist.restart_with_current_binary().await?;
}
seal_writes(&dist, &partial_user, &partial, true).await?;
assert_put_closed(&dist, bucket, &format!("{prefix}closed-{restarted}.bin")).await?;
if part.exists() {
std::fs::remove_file(&part)?;
}
let before = physical_snapshot(&dist, bucket)?;
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
finish_heal(&dist, &token, Some(&repaired.key)).await?;
assert!(!part.exists(), "combined seal admitted a remote repair publication");
assert!(physical_snapshot(&dist, bucket)? == before, "rejected repair changed metadata or shards");
assert_exact_version(&dist.client(2)?, bucket, &repaired.key, &repaired.version, &repaired.body).await?;
let ordinary_part = ordinary_object.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), &ordinary, 3)?;
std::fs::remove_file(ordinary_part)?;
let token = start_heal(&dist, &ordinary, &ordinary_object.key, false).await?;
ordinary_object.assert_repaired(&dist, &ordinary).await?;
finish_heal(&dist, &token, None).await?;
}
Ok(())
}
-7
View File
@@ -18,13 +18,6 @@
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
//! each case starts four real `rustfs` processes.
mod artifact_delete_test;
mod artifact_inventory_test;
mod artifact_put_tail_test;
mod artifact_repair_tail_test;
mod artifact_repair_test;
mod artifact_seal_test;
mod artifact_writes_test;
mod chaos_test;
mod concurrency_stability_test;
mod concurrent_data_movement_test;
@@ -35,6 +35,7 @@ const DEADLINE: Duration = Duration::from_secs(45);
async fn fixture(
drives_per_node: usize,
storage_class: &str,
lock_diagnostics: bool,
) -> TestResult<(RustFSTestClusterEnvironment, Vec<RpcFaultProxy>, Vec<Client>, String)> {
let mut cluster =
RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, drives_per_node)).await?;
@@ -54,6 +55,13 @@ async fn fixture(
] {
cluster.set_env(name, value);
}
if lock_diagnostics {
cluster.set_env(
"RUST_LOG",
"warn,rustfs_lock::distributed_lock=debug,rustfs_ecstore::object_lock_diag=trace,rustfs_ecstore::cluster::rpc::remote_locker=info",
);
cluster.set_env("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true");
}
let mut proxies = Vec::new();
for node in 0..4 {
proxies.push(cluster.start_rpc_proxy_for_node(node).await?);
@@ -316,26 +324,120 @@ fn upload_path_scope(bucket: &str, key: &str, upload_id: &str) -> TestResult<Str
))
}
async fn with_paused_peers<T: Send + 'static>(
async fn with_paused_peers<T, E>(
proxies: &[RpcFaultProxy],
methods: &[&'static str],
limits: &[usize],
scope: &str,
request: impl std::future::Future<Output = T> + Send + 'static,
) -> TestResult<T> {
request: impl std::future::Future<Output = Result<T, SdkError<E>>> + Send + 'static,
) -> TestResult<Result<T, SdkError<E>>>
where
T: Send + 'static,
E: std::error::Error + ProvideErrorMetadata + Send + Sync + 'static,
{
let deadline = tokio::time::Instant::now() + DEADLINE;
let pauses: Vec<_> = proxies[1..=limits.len()]
.iter()
.zip(limits)
.map(|(proxy, limit)| proxy.pause(methods, *limit, Some(scope)))
.collect::<Result<_, _>>()?;
let task = tokio::spawn(request);
wait_for_faults(&pauses, methods).await?;
let result = tokio::time::timeout(DEADLINE, task).await??;
let mut task = tokio::spawn(request);
let mut joined = None;
let mut deadline_expired = false;
let mut barrier_result = {
let faults = wait_for_faults(&pauses, methods);
tokio::pin!(faults);
match tokio::time::timeout_at(deadline, async {
tokio::select! {
result = &mut faults => result,
result = &mut task => {
joined = Some(result);
// A ready request is not evidence that every selected fault
// was reached. Poll the original barrier once for that proof.
match futures::poll!(faults.as_mut()) {
std::task::Poll::Ready(result) => result,
std::task::Poll::Pending => Err("SDK request completed before the selected RPC fault barrier".into()),
}
}
}
})
.await
{
Ok(result) => result,
Err(error) => {
deadline_expired = true;
Err(std::io::Error::new(std::io::ErrorKind::TimedOut, error).into())
}
}
};
// Tokio's timeout polls an already-ready inner future before its timer.
// A late observation cannot certify the original absolute deadline.
deadline_expired |= tokio::time::Instant::now() >= deadline;
if deadline_expired {
barrier_result = Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"selected RPC fault proof observed after the absolute MPU deadline",
)
.into());
}
if barrier_result.is_err() {
for pause in &pauses {
pause.discard_pending();
}
}
let mut client_task_aborted = false;
let request_result = match joined {
Some(result) => result,
None => match tokio::time::timeout_at(deadline, &mut task).await {
Ok(result) => result,
Err(error) => {
deadline_expired = true;
for pause in &pauses {
pause.discard_pending();
}
barrier_result = Err(std::io::Error::new(std::io::ErrorKind::TimedOut, error).into());
// This owns only the test's SDK request, not server/native work.
// Retire the client task without granting another operation budget.
client_task_aborted = true;
task.abort();
task.await
}
},
};
deadline_expired |= tokio::time::Instant::now() >= deadline;
if deadline_expired {
barrier_result = Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"SDK request result observed after the absolute MPU deadline",
)
.into());
}
let hits: Vec<_> = pauses.iter().map(RpcPause::hits).collect();
for pause in &pauses {
pause.discard_pending();
}
drop(pauses);
Ok(result)
if barrier_result.is_err() {
let sdk_error = request_result.as_ref().ok().and_then(|result| result.as_ref().err());
let response = sdk_error.and_then(SdkError::raw_response);
eprintln!(
"MPU fault barrier failed: methods={methods:?}, hits={hits:?}, client_task_aborted={client_task_aborted}, deadline_expired={deadline_expired}, sdk_code={:?}, cause={:?}, request_id={:?}",
sdk_error
.and_then(|error| error.as_service_error())
.and_then(|error| error.meta().code()),
response.and_then(|response| response.headers().get("x-rustfs-error-cause")),
response.and_then(|response| response.headers().get("x-amz-request-id")),
);
}
match (barrier_result, request_result) {
// Preserve the actual SDK source instead of replacing an early service
// failure with an unrelated fault-wait timeout. The outer Err stays red.
(Err(_), Ok(Err(error))) => Err(Box::new(error)),
(Err(_), Err(error)) if !client_task_aborted => Err(Box::new(error)),
(Err(error), _) => Err(error),
(Ok(()), Ok(result)) => Ok(result),
(Ok(()), Err(error)) => Err(Box::new(error)),
}
}
fn quorum_outcome<T: std::fmt::Debug, E: std::fmt::Debug + ProvideErrorMetadata>(
@@ -583,19 +685,19 @@ async fn phase_quorum_matrix(
#[tokio::test]
async fn metadata_delay_preserves_write_quorum_and_rejects_quorum_minus_one() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2", true).await?;
phase_quorum_matrix(&proxies, &clients, &bucket, false, 1).await
}
#[tokio::test]
async fn lock_delay_preserves_admission_quorum_and_reports_timeout() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2", false).await?;
phase_quorum_matrix(&proxies, &clients, &bucket, true, 1).await
}
#[tokio::test]
async fn sixteen_shard_multipart_metadata_requires_twelve_votes() -> TestResult {
let (cluster, proxies, clients, bucket) = fixture(4, "EC:4").await?;
let (cluster, proxies, clients, bucket) = fixture(4, "EC:4", false).await?;
let storage = cluster_admin_ok(&cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: serde_json::Value = serde_json::from_str(&storage)?;
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
@@ -608,7 +710,7 @@ async fn sixteen_shard_multipart_metadata_requires_twelve_votes() -> TestResult
#[tokio::test]
async fn cancelled_metadata_wait_releases_fence_for_bucket_recreation() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2", false).await?;
let pauses: Vec<_> = proxies[1..]
.iter()
.map(|proxy| proxy.pause(&["WriteMetadata"], usize::MAX, Some(&bucket)))
@@ -734,7 +836,7 @@ async fn resume_at_rpc<T: Send + 'static>(
#[tokio::test]
async fn each_multipart_phase_resumes_after_metadata_and_lock_delays() -> TestResult {
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2").await?;
let (_cluster, proxies, clients, bucket) = fixture(1, "EC:2", false).await?;
for locks in [false, true] {
let key = format!("phase-{locks}");
let client = clients[0].clone();
@@ -1206,9 +1206,7 @@ mod tests {
let interval = interval
.filter(|interval| *interval != 0)
.ok_or("scanner schedule has no positive interval")?;
// A new leader may already be sleeping before completing its first cycle.
let schedule_start = if cycle_end == 0 { now } else { cycle_end };
let next_cycle = schedule_start
let next_cycle = cycle_end
.checked_add(interval)
.ok_or("scanner schedule timestamp overflows")?;
let budget = next_cycle
@@ -1225,11 +1223,6 @@ mod tests {
Ok(Duration::from_secs(259)),
);
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
// A newly elected leader can advertise a sleep before its first completed cycle.
assert_eq!(
scanner_cycle_completion_budget(0, Some(289), 1_792_913_211, false),
Ok(Duration::from_secs(349)),
);
}
#[test]
@@ -1250,7 +1243,7 @@ mod tests {
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let mut deadline = Instant::now() + Duration::from_secs(60);
let mut accounted_schedules = HashSet::new();
let mut schedule_accounted = false;
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
@@ -1297,27 +1290,15 @@ mod tests {
return Err("scanner cycle timestamp is in the future".into());
}
let schedule = &status["cycle_schedule"];
if schedule["execution_role"].as_str() == Some("leader") {
if !schedule_accounted && schedule["execution_role"].as_str() == Some("leader") {
let active = metrics["current_cycle_active"]
.as_bool()
.ok_or("scanner status is missing its active-cycle flag")?;
if active || schedule["effective_interval_available"].as_bool() == Some(true) {
let interval = if active {
None
} else {
Some(
schedule["effective_interval_seconds"]
.as_u64()
.ok_or("scanner schedule is missing its effective interval")?,
)
};
// Reconsider a changed leader or retry plan, but never renew
// the deadline on repeated observations of the same sleep.
if accounted_schedules.insert((node_index, cycle_end, interval)) {
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
let scheduled_deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
deadline = deadline.max(scheduled_deadline);
}
if active || (cycle_end != 0 && schedule["effective_interval_available"].as_bool() == Some(true)) {
let interval = schedule["effective_interval_seconds"].as_u64();
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
schedule_accounted = true;
}
}
// Superseded walks complete all sets while withholding quota authority.
@@ -541,50 +541,6 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_repair_rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairRenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn artifact_repair_write_metadata(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairWriteMetadataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteMetadataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
type InventoryArtifactAttemptStream =
Pin<Box<dyn Stream<Item = Result<rustfs_protos::proto_gen::node_service::ArtifactInventoryFrame, Status>> + Send>>;
async fn inventory_artifact_attempt(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactInventoryRequest>,
) -> Result<Response<Self::InventoryArtifactAttemptStream>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn seal_artifact_attempt_repairs(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn seal_artifact_attempt_puts(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
+2 -9
View File
@@ -15,14 +15,12 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{
DiskError, DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
};
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
set_tonic_canonical_body_digest, verify_tonic_boot_epoch_response,
verify_tonic_boot_epoch_response,
};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
@@ -38,11 +36,6 @@ pub(crate) mod grpc_lock {
pub(crate) use super::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
}
#[cfg(test)]
pub(crate) mod artifact_rpc {
pub(crate) use super::{DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
}
/// Signing/transport surface used by the cross-process internode RPC signature
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
/// the one legitimately signed request an on-path attacker is assumed to have
+5 -3
View File
@@ -228,9 +228,11 @@ metrics = { workspace = true }
# The guard scripts/check_no_tokio_io_uring.sh allows an explicit
# io-uring integration; only the tokio "io-uring" runtime feature is banned.
[target.'cfg(target_os = "linux")'.dependencies]
# SharedReadBudget and the bounded read APIs are available in the 0.3.0
# registry release; keep the application on the published contract.
rustfs-uring = "0.3.0"
# Native completion owners keep physical I/O leases alive after cancellation.
rustfs-uring = { git = "https://github.com/rustfs/uring.git", rev = "eb8fc160e71d804427de1e905f5c226d580f71bf" }
[target.'cfg(target_os = "linux")'.dev-dependencies]
rustfs-uring = { git = "https://github.com/rustfs/uring.git", rev = "eb8fc160e71d804427de1e905f5c226d580f71bf", features = ["fault-injection"] }
[target.'cfg(windows)'.dependencies]
winapi-util.workspace = true
+17 -49
View File
@@ -382,43 +382,6 @@ pub mod config {
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
with_server_config_write_lock,
};
/// Observe persisted config bytes and their reader-associated metadata within a byte limit.
pub async fn read_config_observation(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_observation(api, file, max_bytes).await
}
/// Observe bounded config bytes while the caller retains the object's write owner.
pub async fn read_config_observation_no_lock(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
max_bytes: usize,
guard: &crate::object_api::WriteCommitGuard,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_observation_no_lock(api, file, max_bytes, guard).await
}
/// Read config bytes and metadata without treating an empty payload as absent.
pub async fn read_config_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
}
pub async fn read_config_limited_preserve_empty_with_metadata_opts(
api: std::sync::Arc<crate::store::ECStore>,
file: &str,
opts: &crate::object_api::ObjectOptions,
max_bytes: usize,
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
crate::config::com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
}
}
pub mod storageclass {
@@ -464,23 +427,27 @@ pub mod data_usage {
}
pub mod disk {
pub use crate::cluster::rpc::background_pipe::{BackgroundPipeReader, BackgroundPipeWriter, background_pipe};
pub use crate::cluster::rpc::background_reader_stream::BackgroundReaderStream;
pub use crate::disk::background_memory::BackgroundEncodeBuffer;
pub use crate::disk::disk_store::get_object_disk_read_timeout;
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion};
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, LegacyFileStream, LegacyFileWrite,
LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete, LegacyPathDeleteOperation,
LegacyRenameData, LegacyRenameFile, NsScannerOpenRequest, OldCurrentSize, PartTransactionAction,
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path,
new_disk, validate_batch_read_version_item_count,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
is_quota_mutation_fence_path, new_disk, validate_batch_read_version_item_count,
};
pub use crate::disk::{
BackgroundMemoryBudget, PhysicalDiskBinding, PhysicalDiskPlan, PhysicalDiskSample, PhysicalDiskSampler,
PhysicalResourcePlan, RemoteBackgroundIo, with_background_resource_unit, with_remote_background_io,
};
pub use bytes::Bytes;
pub use endpoint::Endpoint;
@@ -619,6 +586,7 @@ pub mod rio {
}
pub mod rpc {
pub use crate::cluster::rpc::http_auth::with_rpc_background_request;
pub use crate::cluster::rpc::{
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, MAX_NETWORK_PROBE_BYTES, MAX_NETWORK_PROBE_DURATION,
NetworkPeerProbeClient, NetworkPeerProbeError, NetworkPeerProbeMeasurement, NetworkPeerTarget, PEER_RESTDRY_RUN,
@@ -628,8 +596,8 @@ pub mod rpc {
TONIC_RPC_PREFIX, TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce,
decode_heal_bucket_rpc_options, encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers,
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, rpc_background_admission,
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
@@ -675,13 +643,13 @@ pub mod store_list {
}
pub mod storage {
pub use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
pub use crate::store::HealWalkVersion;
pub use crate::store::{
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
all_local_disk_path, find_local_disk_by_ref, find_local_disk_by_ref_for_rpc, init_local_disks,
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
prewarm_local_disk_id_map_with_instance_ctx,
};
pub use crate::store::{HealObjectAbsenceProof, HealObjectStorageResult};
}
File diff suppressed because it is too large Load Diff
@@ -1,120 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
use crate::config::com::read_config_observation;
use crate::object_api::WriteCompletion;
impl ECStore {
/// Permanently retain this managed bucket's versions for historical restores.
/// Lifecycle WRITE drains earlier exact deletes. The detached owner keeps
/// lifecycle -> metadata transaction locks until every metadata write drains.
pub async fn retain_artifact_backups(self: Arc<Self>, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<()> {
let bucket = bucket.to_owned();
let base_prefix = base_prefix.to_owned();
let ctx = Arc::clone(&self.ctx);
ctx.run_detached_mutation(async move {
require_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact backup retention requires namespace locking"));
}
let lifecycle = self.acquire_bucket_lifecycle_write_lock(&bucket).await?;
let transaction = metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, &bucket).await?;
let mut metadata = self.backup_retention_metadata(&bucket, incarnation, &base_prefix).await?;
let check = || {
if lifecycle.is_lock_lost() || transaction.is_lock_lost() {
Err(Error::other("artifact backup retention fence was lost"))
} else {
Ok(())
}
};
check()?;
// Even an idempotent retry rewrites with TailDrained: an earlier
// interrupted call may have reached only some metadata replicas.
metadata.artifact_backup_retained = true;
let mut opts = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
opts.add_bucket_lifecycle_lock_guard(&lifecycle);
opts.add_namespace_lock_guard(&transaction);
metadata.save_with_store_options(self.clone(), &opts).await?;
check()?;
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
let sys = sys.read().await.clone();
sys.set_validated(metadata).await?;
check()
})
.await
.map_err(|_| Error::other("artifact backup retention owner failed"))?
}
/// Observe the immutable bucket identity and retention in one locked read.
pub async fn inspect_artifact_backup_retention(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<bool> {
require_put_seal_topology(self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact backup retention requires namespace locking"));
}
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let metadata = self.backup_retention_metadata(bucket, incarnation, base_prefix).await?;
if lifecycle.is_lock_lost() {
return Err(Error::other("artifact backup retention observation fence was lost"));
}
Ok(metadata.artifact_backup_retained)
}
async fn backup_retention_metadata(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<BucketMetadata> {
if incarnation.is_nil() || !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(base_prefix) {
return Err(Error::other("artifact backup retention identity is invalid"));
}
let mut metadata = self.read_backup_retention_metadata(bucket).await?;
let store = metadata_sys::object_store_in(&self.ctx).await?;
let (sidecar, _) =
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
metadata.parse_all_configs()?;
if metadata.bucket_incarnation_id != incarnation
|| Uuid::from_slice(&sidecar).ok() != Some(incarnation)
|| metadata.artifact_base_prefix.as_deref() != Some(base_prefix)
|| !metadata.bucket_creation_committed
|| !metadata.versioning_config.as_ref().is_some_and(|versioning| {
versioning.enabled()
&& !versioning.exclude_folders.unwrap_or(false)
&& versioning.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
})
{
return Err(Error::other("artifact backup retention identity mismatch"));
}
metadata.bucket_incarnation_sidecar = true;
Ok(metadata)
}
/// Caller must hold the bucket lifecycle fence. An unreadable or missing
/// record cannot prove that historical backup protection was never enabled.
pub(crate) async fn require_no_artifact_backup_retention(&self, bucket: &str) -> Result<()> {
if self.read_backup_retention_metadata(bucket).await?.artifact_backup_retained {
return Err(Error::other("artifact versions are retained for backups"));
}
Ok(())
}
async fn read_backup_retention_metadata(&self, bucket: &str) -> Result<BucketMetadata> {
if bucket.is_empty()
|| super::super::utils::has_bad_path_component(bucket)
|| super::super::utils::is_meta_bucketname(bucket)
{
return Err(Error::other("artifact backup retention bucket is invalid"));
}
let store = metadata_sys::object_store_in(&self.ctx).await?;
if store.id != self.id {
return Err(Error::other("artifact backup retention belongs to another store"));
}
let (bytes, _) = read_config_observation(store, &BucketMetadata::new(bucket).save_file_path(), 64 * 1024 * 1024).await?;
let metadata = BucketMetadata::decode_file(&bytes)?;
if metadata.name != bucket {
return Err(Error::other("artifact backup retention bucket mismatch"));
}
Ok(metadata)
}
}
@@ -1,154 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::storage_api_contracts::object::ObjectOperations as _;
/// In-process authority for exactly one drained managed version. No caller can
/// construct this capability or serialize it into a disk/RPC request.
#[doc(hidden)]
pub struct ArtifactDeleteTarget {
store: Uuid,
record: ArtifactVersionRetirementRecord,
lifecycle: Arc<rustfs_lock::NamespaceLockGuard>,
attempt: WriteCommitGuard,
repair: WriteCommitGuard,
}
impl ArtifactDeleteTarget {
fn check_live(&self) -> Result<()> {
if self.lifecycle.is_lock_lost() || self.attempt.is_lock_lost() || self.repair.is_lock_lost() {
return Err(Error::other("artifact exact delete owned fence was lost"));
}
Ok(())
}
pub(crate) fn lifecycle(&self) -> Arc<rustfs_lock::NamespaceLockGuard> {
Arc::clone(&self.lifecycle)
}
pub(crate) fn validate(&self, store: &ECStore, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
if self.store != store.id
|| bucket != self.record.bucket
|| rustfs_utils::path::encode_dir_object(object) != self.record.object
|| opts.expected_bucket_incarnation_id != Some(self.record.incarnation_id)
|| opts.version_id.as_deref() != Some(self.record.version_id.to_string().as_str())
|| !opts.versioned
|| opts.version_suspended
|| opts.delete_prefix
|| opts.delete_marker
|| opts.data_movement
|| opts.lifecycle_delete_all.is_some()
|| self.lifecycle.is_lock_lost()
|| self.attempt.is_lock_lost()
|| self.repair.is_lock_lost()
{
return Err(Error::other("artifact exact delete target or owned fence mismatch"));
}
Ok(())
}
}
impl ECStore {
/// Read-only capability observation; each mutation revalidates the topology.
pub async fn supports_artifact_exact_delete(&self) -> bool {
require_local_put_seal_topology(self).await.is_ok() && !self.ctx.lock_manager().is_disabled()
}
/// Delete an exact version in a fully sealed attempt. This authenticates the
/// storage identity and drains writers; the caller still owns GC eligibility
/// and cleanup IAM. Distributed receiver deletion is not yet supported.
pub async fn delete_artifact_object_version(
self: Arc<Self>,
bucket: &str,
expected_incarnation: Uuid,
attempt_prefix: &str,
object: &str,
version: Uuid,
) -> Result<()> {
require_local_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() {
return Err(Error::other("artifact exact deletion requires namespace locking"));
}
let lifecycle = Arc::new(self.acquire_bucket_lifecycle_read_lock(bucket).await?);
self.require_no_artifact_backup_retention(bucket).await?;
let put = self
.inventory_artifact_record(bucket, expected_incarnation, attempt_prefix)
.await?;
let repair = ArtifactRepairSealRecord::new(&put);
let record = ArtifactVersionRetirementRecord::new(&put, object, version)?;
let object = object.to_owned();
let store = Arc::clone(&self);
// Lifecycle -> attempt -> repair seal -> metadata transaction -> object.
// The spawned owner, and the disk deletion tail's typed capability, keep
// these locks after cancellation of the HTTP caller.
with_config_object_write_lock(self, put.path(), move |attempt| async move {
if !put.exists(Arc::clone(&store), &attempt).await? {
return Err(Error::other("artifact exact deletion requires sealed PUTs"));
}
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
if !repair.exists(Arc::clone(&store), &repair_owner).await? {
return Err(Error::other("artifact exact deletion requires sealed repairs"));
}
let receivers = store.capture_artifact_receivers().await?;
store
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Put, &receivers)
.await?;
store
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Repair, &receivers)
.await?;
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
let target = Arc::new(ArtifactDeleteTarget {
store: store.id,
record,
lifecycle,
attempt,
repair: repair_owner,
});
let mut opts = ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
expected_bucket_incarnation_id: Some(expected_incarnation),
artifact_delete_target: Some(Arc::clone(&target)),
delete_mutation_owner: Some(target.clone()),
..Default::default()
};
opts.add_write_commit_guard(&target.attempt);
opts.add_write_commit_guard(&target.repair);
// These owners fence seal records, not the object. Ordinary DELETE
// must still acquire and retain its exact object WRITE owner.
opts.no_lock = false;
target.check_live()?;
// Preserve the normal exact DELETE's Object Lock, quorum, journal,
// cache and capacity behavior. Its success is logical deletion, not
// a claim that every offline shard has been physically erased.
match store.delete_object(&put.bucket, &object, opts).await {
Ok(info) => {
// Match the S3 request layer's committed-generation quota
// delta. A strict absence retry must not subtract twice.
if !info.name.is_empty()
&& !info.delete_marker
&& let Ok(size) = crate::data_usage::quota_object_size(&info)
{
crate::data_usage::record_bucket_object_delete_memory(&put.bucket, size, false).await;
}
}
Err(Error::VersionNotFound(bucket, object, version))
if bucket == target.record.bucket
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object
&& version == target.record.version_id.to_string() => {}
Err(Error::ObjectNotFound(bucket, object))
if bucket == target.record.bucket
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object => {}
Err(error) => return Err(error),
}
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
store
.inventory_artifact_record(&put.bucket, expected_incarnation, &put.attempt_prefix)
.await?;
target.check_live()
})
.await?
}
}
@@ -1,207 +0,0 @@
// Copyright 2026 RustFS Team
// SPDX-License-Identifier: Apache-2.0
use super::*;
use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion, Disk};
use rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase;
use tokio::{sync::mpsc, time::Instant};
/// Physical observations retain their member. Replicas are not extra business
/// versions and this stream makes no quorum-readability or GC claim.
#[derive(Debug)]
pub enum ArtifactAttemptInventoryFrame {
Observations {
endpoint: String,
disk_id: Uuid,
versions: Vec<ArtifactInventoryVersion>,
},
Complete {
members: usize,
observations: u64,
},
}
impl ECStore {
/// Inventory one fully sealed attempt across every configured receiver.
/// Earlier output is staging only; only the final frame plus Ok is complete.
pub async fn inventory_artifact_attempt(
self: Arc<Self>,
bucket: &str,
incarnation: Uuid,
prefix: &str,
deadline: Instant,
output: mpsc::Sender<ArtifactAttemptInventoryFrame>,
) -> Result<()> {
tokio::time::timeout_at(deadline, Box::pin(async move {
require_put_seal_topology(&self).await?;
if self.ctx.lock_manager().is_disabled() { return Err(Error::other("artifact inventory requires namespace locking")); }
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
let put = self.inventory_artifact_record(bucket, incarnation, prefix).await?;
let repair = ArtifactRepairSealRecord::new(&put);
// Lifecycle -> attempt -> repair record. Receivers do not reenter
// distributed locks; actual two-domain disk owners guard their reads.
let store = self.clone();
let bucket = bucket.to_owned();
let prefix = prefix.to_owned();
with_config_object_write_lock(self, put.path(), move |attempt| async move {
tokio::time::timeout_at(deadline, async {
if !put.exists(store.clone(), &attempt).await? { return Err(Error::other("artifact inventory requires PUT seal record")); }
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
if !repair.exists(store.clone(), &repair_owner).await? { return Err(Error::other("artifact inventory requires repair seal record")); }
let receivers = store.capture_artifact_receivers().await?;
let mut observations = 0u64;
for (endpoint, (disk, expected)) in &receivers {
if lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
return Err(Error::other("artifact inventory fence was lost"));
}
let (sender, mut frames) = mpsc::channel(2);
let scan = async {
match disk.as_ref() {
Disk::Local(local) => local.get_disk().inventory_artifact_attempt(*expected, &bucket, &prefix, put.identity(), deadline, sender).await,
Disk::Remote(remote) => remote.inventory_artifact_attempt(*expected, (&bucket, incarnation), &prefix, ArtifactInventoryPhase::ArtifactInventoryScan, deadline, sender).await,
}
};
tokio::pin!(scan);
let mut sequence = 0;
let mut count = 0u64;
let mut terminal = false;
let mut ended = false;
let mut done = false;
while !ended || !done {
tokio::select! {
result = &mut scan, if !done => { result.map_err(Error::other)?; done = true; },
frame = frames.recv(), if !ended => match frame {
Some(ArtifactInventoryFrame::Entries { sequence: actual, versions }) if !terminal && actual == sequence => {
count = count.checked_add(u64::try_from(versions.len()).map_err(Error::other)?).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
output.send(ArtifactAttemptInventoryFrame::Observations { endpoint: endpoint.clone(), disk_id: *expected, versions }).await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
sequence += 1;
}
Some(ArtifactInventoryFrame::Complete { disk_id, incarnation: actual, attempt_prefix, entries })
if !terminal && disk_id == *expected && actual == incarnation && attempt_prefix == prefix && entries == count => terminal = true,
Some(_) => return Err(Error::other("artifact inventory member stream is invalid")),
None => ended = true,
},
}
}
if !terminal { return Err(Error::other("artifact inventory member is incomplete")); }
observations = observations.checked_add(count).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
}
store.verify_inventory_members(&put, &receivers, deadline).await?;
let current = store.artifact_inventory_receiver_identity(&bucket, incarnation, &prefix).await?;
if current.receiver_key != put.identity().receiver_key || lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
return Err(Error::other("artifact inventory authority changed"));
}
output.send(ArtifactAttemptInventoryFrame::Complete { members: receivers.len(), observations }).await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
Ok(())
}).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
}).await?
})).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
}
pub(super) async fn inventory_artifact_record(&self, bucket: &str, expected: Uuid, prefix: &str) -> Result<PutSealRecord> {
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
use crate::config::com::read_config_observation;
if expected.is_nil()
|| crate::bucket::utils::has_bad_path_component(bucket)
|| bucket.is_empty()
|| crate::bucket::utils::is_meta_bucketname(bucket)
{
return Err(Error::other("artifact inventory authority is invalid"));
}
let store = metadata_sys::object_store_in(&self.ctx).await?;
if store.id != self.id {
return Err(Error::other("artifact inventory authority belongs to another store"));
}
// Do not enter metadata_sys's lazy bucket READ/migration path: the
// coordinator already owns lifecycle READ and a writer may be queued.
// Observation reads bypass cache and suppress physical read repair.
let path = BucketMetadata::new(bucket).save_file_path();
let (bytes, _) = read_config_observation(store.clone(), &path, 64 * 1024 * 1024).await?;
let mut metadata = BucketMetadata::decode_file(&bytes)?;
let (sidecar, _) =
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
if metadata.name != bucket
|| !metadata.bucket_creation_committed
|| metadata.bucket_incarnation_id != expected
|| Uuid::from_slice(&sidecar).ok() != Some(expected)
{
return Err(Error::other("artifact inventory incarnation mismatch"));
}
metadata.parse_all_configs()?;
if !metadata.versioning_config.as_ref().is_some_and(|config| {
config.enabled()
&& !config.exclude_folders.unwrap_or(false)
&& config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
}) {
return Err(Error::other("artifact inventory requires managed versioning"));
}
PutSealRecord::new(&metadata, prefix)
}
pub(crate) async fn artifact_inventory_receiver_identity(
&self,
bucket: &str,
expected: Uuid,
prefix: &str,
) -> Result<ArtifactPutIdentity> {
Ok(self.inventory_artifact_record(bucket, expected, prefix).await?.identity())
}
async fn verify_inventory_members(
&self,
record: &PutSealRecord,
captured: &ArtifactReceiverSnapshot,
deadline: Instant,
) -> Result<()> {
for final_pass in [false, true] {
let current = self.artifact_receiver_inventory().await?;
if current.len() != captured.len()
|| current
.iter()
.any(|(endpoint, disk)| !captured.get(endpoint).is_some_and(|(old, _)| Arc::ptr_eq(old, disk)))
{
return Err(Error::other("artifact inventory membership changed"));
}
if final_pass {
for (disk, expected) in captured.values() {
if disk.cached_disk_id().await != Some(*expected) {
return Err(Error::other("artifact inventory expected identity changed"));
}
}
break;
}
for (disk, expected) in captured.values() {
if disk.cached_disk_id().await != Some(*expected) {
return Err(Error::other("artifact inventory expected identity changed"));
}
match disk.as_ref() {
Disk::Local(local) => {
let _verification = local
.get_disk()
.verify_artifact_inventory_closed(*expected, record.identity(), deadline)
.await
.map_err(Error::other)?;
}
Disk::Remote(remote) => {
let (output, _unused) = mpsc::channel(1);
remote
.inventory_artifact_attempt(
*expected,
(&record.bucket, record.incarnation_id),
&record.attempt_prefix,
ArtifactInventoryPhase::ArtifactInventoryVerify,
deadline,
output,
)
.await
.map_err(Error::other)?;
}
}
}
}
Ok(())
}
}
@@ -1,287 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Weak};
/// Shares only reads started after a caller arrives; retains no idle results.
#[derive(Default)]
pub(crate) struct OrdinaryClassificationRegistry {
gates: Mutex<HashMap<String, Weak<Gate>>>,
#[cfg(test)]
after_unregister: Option<std::sync::Barrier>,
}
#[derive(Default)]
struct Gate {
started_generation: AtomicU64,
completed: tokio::sync::Mutex<Option<CompletedRead>>,
}
struct CompletedRead {
generation: u64,
allowed: bool,
}
struct Registration<'a> {
registry: &'a OrdinaryClassificationRegistry,
bucket: &'a str,
gate: Option<Arc<Gate>>,
observed_generation: u64,
}
impl OrdinaryClassificationRegistry {
pub(crate) async fn classify(&self, bucket: &str, read: impl Future<Output = bool>) -> bool {
self.register(bucket).classify(read).await
}
fn register<'a>(&'a self, bucket: &'a str) -> Registration<'a> {
// Callers requiring physical admission must register after acquiring it.
// Release the registry lock before awaiting the gate, then acquire the
// existing metadata locks.
let mut gates = self.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let gate = gates.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
let gate = Arc::new(Gate::default());
gates.insert(bucket.to_owned(), Arc::downgrade(&gate));
gate
});
let observed_generation = gate.started_generation.load(Ordering::Acquire);
Registration {
registry: self,
bucket,
gate: Some(gate),
observed_generation,
}
}
}
impl Registration<'_> {
async fn classify(self, read: impl Future<Output = bool>) -> bool {
let Some(gate) = self.gate.as_ref() else {
return false;
};
let mut completed = gate.completed.lock().await;
// Only a read started after this caller registered can authorize it.
// Arrivals during a read therefore wait for the next fresh read.
if let Some(result) = completed
.as_ref()
.filter(|result| result.generation > self.observed_generation)
{
return result.allowed;
}
let Ok(previous) = gate
.started_generation
.try_update(Ordering::AcqRel, Ordering::Acquire, |generation| generation.checked_add(1))
else {
return false;
};
// Keep the gate through the original bounded metadata read. Cancellation
// releases it without publishing a result; another caller can read again.
let allowed = read.await;
*completed = Some(CompletedRead {
generation: previous + 1,
allowed,
});
allowed
}
}
impl Drop for Registration<'_> {
fn drop(&mut self) {
let Some(gate) = self.gate.take() else {
return;
};
let mut gates = self.registry.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Strong references are acquired and released under this registry lock,
// so concurrent final callers cannot both miss the last reference.
if Arc::strong_count(&gate) == 1
&& gates
.get(self.bucket)
.is_some_and(|registered| registered.ptr_eq(&Arc::downgrade(&gate)))
{
gates.remove(self.bucket);
}
drop(gate);
drop(gates);
#[cfg(test)]
if let Some(barrier) = &self.registry.after_unregister {
barrier.wait();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::poll;
use std::task::Poll;
use tokio::sync::oneshot;
fn assert_empty(registry: &OrdinaryClassificationRegistry) {
assert!(registry.gates.lock().expect("classification registry").is_empty());
}
#[tokio::test]
async fn late_arrivals_share_only_the_next_fail_closed_read() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
let (release, waiting) = oneshot::channel();
let mut first = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
waiting.await.expect("release first read");
true
}));
assert!(poll!(first.as_mut()).is_pending());
let mut followers = (0..16)
.map(|_| {
Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
false
}))
})
.collect::<Vec<_>>();
for follower in &mut followers {
assert!(poll!(follower.as_mut()).is_pending());
}
release.send(()).expect("first reader still alive");
assert_eq!(poll!(first.as_mut()), Poll::Ready(true));
for follower in &mut followers {
assert_eq!(poll!(follower.as_mut()), Poll::Ready(false));
}
assert_eq!(reads.load(Ordering::Relaxed), 2);
assert_empty(&registry);
}
#[tokio::test]
async fn successive_waves_always_read_again() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
for expected in [true, false, true] {
assert_eq!(
registry
.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
expected
})
.await,
expected
);
assert_empty(&registry);
}
assert_eq!(reads.load(Ordering::Relaxed), 3);
}
#[tokio::test]
async fn canceled_leader_does_not_publish_or_strand_waiters() {
let registry = OrdinaryClassificationRegistry::default();
let reads = AtomicU64::new(0);
let old_registration = registry.register("bucket");
assert!(
registry
.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
true
})
.await
);
let mut leader = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
std::future::pending::<bool>().await
}));
assert!(poll!(leader.as_mut()).is_pending());
let mut old_waiter =
Box::pin(old_registration.classify(async { panic!("old waiter must reuse the completed post-registration read") }));
assert!(poll!(old_waiter.as_mut()).is_pending());
let mut late_waiter = Box::pin(registry.classify("bucket", async {
reads.fetch_add(1, Ordering::Relaxed);
false
}));
assert!(poll!(late_waiter.as_mut()).is_pending());
drop(leader);
assert_eq!(poll!(old_waiter.as_mut()), Poll::Ready(true));
assert_eq!(poll!(late_waiter.as_mut()), Poll::Ready(false));
assert_eq!(reads.load(Ordering::Relaxed), 3);
assert_empty(&registry);
}
#[tokio::test]
async fn canceled_follower_does_not_retain_the_gate() {
let registry = OrdinaryClassificationRegistry::default();
let (release, waiting) = oneshot::channel();
let mut leader = Box::pin(registry.classify("bucket", async {
waiting.await.expect("release leader");
true
}));
assert!(poll!(leader.as_mut()).is_pending());
let mut follower = Box::pin(registry.classify("bucket", async { panic!("canceled follower must not read") }));
assert!(poll!(follower.as_mut()).is_pending());
drop(follower);
assert_eq!(registry.gates.lock().expect("classification registry").len(), 1);
release.send(()).expect("leader still alive");
assert_eq!(poll!(leader.as_mut()), Poll::Ready(true));
assert_empty(&registry);
}
#[test]
fn concurrent_final_registrations_remove_the_gate() {
let registry = OrdinaryClassificationRegistry {
after_unregister: Some(std::sync::Barrier::new(2)),
..Default::default()
};
let first = registry.register("bucket");
let second = registry.register("bucket");
// Both destructors reach the unlocked barrier before either returns.
// Moving the Arc release after this barrier leaves a stale registry key.
std::thread::scope(|scope| {
scope.spawn(move || drop(first));
scope.spawn(move || drop(second));
});
assert_empty(&registry);
}
#[tokio::test]
async fn buckets_and_stores_do_not_share_gates() {
let first_store = OrdinaryClassificationRegistry::default();
let second_store = OrdinaryClassificationRegistry::default();
let mut blocked = Box::pin(first_store.classify("first", std::future::pending()));
assert!(poll!(blocked.as_mut()).is_pending());
assert_eq!(poll!(Box::pin(first_store.classify("second", async { true }))), Poll::Ready(true));
assert_eq!(poll!(Box::pin(second_store.classify("first", async { false }))), Poll::Ready(false));
assert_empty(&second_store);
drop(blocked);
assert_empty(&first_store);
}
#[tokio::test]
async fn generation_exhaustion_fails_closed_without_reading() {
let registry = OrdinaryClassificationRegistry::default();
let registration = registry.register("bucket");
let gate = registration.gate.as_ref().expect("registered gate");
gate.started_generation.store(u64::MAX, Ordering::Release);
*gate.completed.lock().await = Some(CompletedRead {
generation: u64::MAX,
allowed: true,
});
assert!(
!registry
.classify("bucket", async { panic!("exhausted generation must deny without reading") })
.await
);
assert_eq!(gate.started_generation.load(Ordering::Acquire), u64::MAX);
drop(registration);
assert_empty(&registry);
}
}
@@ -890,7 +890,6 @@ pub(crate) async fn cleanup_free_version_exact(
let bucket_guard = api
.acquire_bucket_lifecycle_read_lock(&oi.bucket)
.await
.map(Arc::new)
.map_err(std::io::Error::other)?;
let (lease, delete_plan) = acquire_free_version_tier_lease(oi, &api.tier_config_mgr()).await?;
let local_object = encode_dir_object(&oi.name);
@@ -959,7 +958,7 @@ pub(crate) async fn cleanup_free_version_exact(
};
delete_request.set_tier_free_version();
if let Err(err) = set
.delete_object_version_with_lifecycle_owner(&oi.bucket, &local_object, &delete_request, Arc::clone(&bucket_guard))
.delete_object_version(&oi.bucket, &local_object, &delete_request, false)
.await
&& first_error.is_none()
{
@@ -8497,104 +8496,6 @@ mod tests {
}
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial]
async fn artifact_delete_entrypoints_free_version_cleaner_completes_with_queued_lifecycle_writer() {
let (disk_paths, ecstore) = setup_test_env().await;
let bucket = format!("free-version-queued-writer-{}", Uuid::new_v4());
let object = "free-version";
create_test_bucket(&ecstore, &bucket).await;
let (backend, identity) = register_recovery_mock_tier(&ecstore).await;
seed_recoverable_free_version(&disk_paths, &bucket, object, None, Some(identity)).await;
let oi = list_tier_free_versions(Arc::clone(&ecstore), 100, None, None, CancellationToken::new())
.await
.expect("list real persisted free version")
.items
.into_iter()
.find(|oi| oi.bucket == bucket)
.expect("free-version task must address this fixture");
assert!(oi.transitioned_object.free_version);
backend
.set_put_remote_version(Some(oi.transitioned_object.version_id.clone()))
.await;
let remote = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
.await
.expect("remote fixture lease");
remote
.put(&oi.transitioned_object.name, ReaderImpl::Body(Bytes::from_static(b"body")), 4)
.await
.expect("seed exact remote object");
drop(remote);
let barrier = Arc::new(super::FreeVersionPostRemoteDeleteTestBarrier::default());
let cleanup_barrier = Arc::clone(&barrier);
let cleanup_store = Arc::clone(&ecstore);
let cleanup_oi = oi.clone();
let mut cleanup = tokio::spawn(async move {
super::FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER
.scope(cleanup_barrier, async move {
super::cleanup_free_version_exact(cleanup_store, &cleanup_oi, &CancellationToken::new()).await
})
.await
});
tokio::time::timeout(StdDuration::from_secs(10), barrier.arrived.notified())
.await
.expect("cleaner owns lifecycle READ and has completed remote DELETE");
assert!(!backend.contains(&oi.transitioned_object.name).await);
for disk in &disk_paths {
assert!(
disk.join(&bucket).join(object).join(STORAGE_FORMAT_FILE).is_file(),
"local exact cleanup must still be pending at the boundary"
);
}
// Resolve first so the pending poll is the actual local lock wait.
// A later READ would normally be admitted alongside the cleaner's READ;
// its pending state independently proves this WRITE is now queued.
let lifecycle = ecstore
.new_ns_lock(&bucket, crate::storage_api_contracts::bucket::BUCKET_LIFECYCLE_LOCK_OBJECT)
.await
.expect("lifecycle namespace");
let mut writer = Box::pin(lifecycle.get_write_lock(StdDuration::from_secs(30)));
let writer_queued = futures::poll!(&mut writer).is_pending();
let mut later_reader = Box::pin(lifecycle.get_read_lock(StdDuration::from_secs(30)));
let later_reader_blocked = futures::poll!(&mut later_reader).is_pending();
drop(later_reader);
barrier.release.notify_one();
assert!(writer_queued, "cleaner must retain the real lifecycle READ owner");
assert!(later_reader_blocked, "queued lifecycle WRITE must block a later READ");
// Keep the same WRITE future queued, without cancelling/retrying it.
// Re-entering READ here would deadlock behind this waiting writer.
let finished = tokio::time::timeout(StdDuration::from_secs(10), &mut cleanup).await;
if finished.is_err() {
cleanup.abort();
drop(writer);
let _ = tokio::time::timeout(StdDuration::from_secs(5), &mut cleanup).await;
panic!("exact local free-version cleanup re-entered lifecycle READ behind the queued writer");
}
assert!(
finished
.expect("bounded cleaner completion")
.expect("cleaner task")
.expect("exact local cleanup")
);
let writer = tokio::time::timeout(StdDuration::from_secs(10), writer)
.await
.expect("queued lifecycle WRITE proceeds after real cleanup drains")
.expect("lifecycle WRITE acquired");
for disk in &disk_paths {
assert!(!disk.join(&bucket).join(object).exists(), "free version remains on a real local disk");
}
assert_eq!(backend.remove_count().await, 1, "cleaner must delete the exact remote tuple once");
drop(writer);
ecstore
.delete_bucket(&bucket, &DeleteBucketOptions::default())
.await
.expect("remove empty fixture bucket");
}
#[cfg(feature = "test-util")]
#[tokio::test]
#[serial]
+18 -212
View File
@@ -437,8 +437,6 @@ pub struct BucketMetadata {
pub created: OffsetDateTime,
pub lock_enabled: bool, // While marked as unused, it may need to be retained
pub bucket_incarnation_id: Uuid,
pub artifact_base_prefix: Option<String>,
pub artifact_backup_retained: bool,
pub(crate) bucket_incarnation_sidecar: bool,
pub(crate) bucket_creation_committed: bool,
pub policy_config_json: Vec<u8>,
@@ -514,8 +512,6 @@ impl Default for BucketMetadata {
created: OffsetDateTime::UNIX_EPOCH,
lock_enabled: Default::default(),
bucket_incarnation_id: Uuid::nil(),
artifact_base_prefix: None,
artifact_backup_retained: false,
bucket_incarnation_sidecar: false,
bucket_creation_committed: false,
policy_config_json: Default::default(),
@@ -611,7 +607,7 @@ impl BucketMetadata {
/// legacy migration path, which mints the sidecar first; it must not be
/// rewritten by this migration.
pub(crate) fn needs_bucket_creation_commit(&self) -> bool {
(self.lock_enabled || self.artifact_base_prefix.is_some())
self.lock_enabled
&& !self.bucket_creation_committed
&& self.bucket_incarnation_sidecar
&& !self.bucket_incarnation_id.is_nil()
@@ -708,22 +704,6 @@ impl BucketMetadata {
self.bucket_incarnation_id =
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
}
"ArtifactBackupRetained" => {
if self.artifact_backup_retained || !rmp::decode::read_bool(rd)? {
return Err(Error::other("invalid artifact backup retention marker"));
}
self.artifact_backup_retained = true;
}
"ArtifactBasePrefix" => {
if self.artifact_base_prefix.is_some() {
return Err(Error::other("duplicate artifact namespace"));
}
let prefix = read_msgp_str(rd)?;
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(&prefix) {
return Err(Error::other("invalid artifact namespace"));
}
self.artifact_base_prefix = Some(prefix);
}
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
@@ -786,7 +766,7 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Map size: MinIO fields (25) + RustFS extensions (22)
let map_len: u32 = 47 + u32::from(self.artifact_base_prefix.is_some()) + u32::from(self.artifact_backup_retained);
let map_len: u32 = 47;
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -800,15 +780,6 @@ impl BucketMetadata {
rmp::encode::write_bool(wr, self.lock_enabled)?;
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
if let Some(prefix) = &self.artifact_base_prefix {
rmp::encode::write_str(wr, "ArtifactBasePrefix")?;
rmp::encode::write_str(wr, prefix)?;
}
if self.artifact_backup_retained {
rmp::encode::write_str(wr, "ArtifactBackupRetained")?;
rmp::encode::write_bool(wr, true)?;
}
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
@@ -909,28 +880,13 @@ impl BucketMetadata {
}
match version {
BUCKET_METADATA_VERSION | 2 => {}
BUCKET_METADATA_VERSION => {}
_ => return Err(Error::other("read_bucket_metadata: version invalid")),
}
Ok(())
}
/// Version two is reserved for permanent artifact backup retention. Older
/// readers reject its header instead of skipping the protection field.
pub(crate) fn decode_file(bytes: &[u8]) -> Result<Self> {
Self::check_header(bytes)?;
let metadata = Self::unmarshal(&bytes[4..])?;
let retained_header = LittleEndian::read_u16(&bytes[2..4]) == 2;
if retained_header != metadata.artifact_backup_retained {
return Err(Error::other("artifact backup retention header mismatch"));
}
if metadata.artifact_backup_retained {
metadata.validate_artifact_configuration()?;
}
Ok(metadata)
}
pub(crate) fn default_timestamps(&mut self) {
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.policy_config_updated_at = self.created
@@ -1069,9 +1025,6 @@ impl BucketMetadata {
}
Some(config)
};
if self.artifact_base_prefix.is_some() && !config.as_ref().is_some_and(artifact_versioning_enabled) {
return Err(Error::other("artifact bucket versioning must remain enabled"));
}
self.versioning_config_xml = data;
self.versioning_config = config;
self.versioning_config_updated_at = updated;
@@ -1174,34 +1127,13 @@ impl BucketMetadata {
&mut self,
store: std::sync::Arc<crate::store::ECStore>,
write_completion: WriteCompletion,
) -> Result<()> {
let opts = crate::object_api::ObjectOptions {
max_parity: true,
write_completion,
..Default::default()
};
self.save_with_store_options(store, &opts).await
}
pub(crate) async fn save_with_store_options(
&mut self,
store: Arc<ECStore>,
opts: &crate::object_api::ObjectOptions,
) -> Result<()> {
self.parse_all_configs()?;
self.validate_artifact_update(store.clone()).await?;
let mut buf: Vec<u8> = vec![0; 4];
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
LittleEndian::write_u16(
&mut buf[2..4],
if self.artifact_backup_retained {
2
} else {
BUCKET_METADATA_VERSION
},
);
LittleEndian::write_u16(&mut buf[2..4], BUCKET_METADATA_VERSION);
let data = self
.marshal_msg()
@@ -1209,52 +1141,21 @@ impl BucketMetadata {
buf.extend_from_slice(&data);
save_config_with_opts(store, self.save_file_path().as_str(), buf, opts).await?;
save_config_with_opts(
store,
self.save_file_path().as_str(),
buf,
&crate::object_api::ObjectOptions {
max_parity: true,
write_completion,
..Default::default()
},
)
.await?;
Ok(())
}
fn validate_artifact_configuration(&self) -> Result<()> {
if self.artifact_backup_retained && (self.artifact_base_prefix.is_none() || !self.bucket_creation_committed) {
return Err(Error::other("artifact backup retention requires a committed managed bucket"));
}
if let Some(prefix) = &self.artifact_base_prefix {
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(prefix) || self.bucket_incarnation_id.is_nil() {
return Err(Error::other("invalid artifact bucket identity"));
}
let versioning = deserialize::<VersioningConfiguration>(&self.versioning_config_xml)?;
if !artifact_versioning_enabled(&versioning) {
return Err(Error::other("artifact bucket versioning must remain enabled"));
}
}
Ok(())
}
/// Full metadata replacement must not change a generation's creation identity.
pub(crate) async fn validate_artifact_update(&self, store: Arc<ECStore>) -> Result<()> {
self.validate_artifact_configuration()?;
// An existing empty/corrupt metadata object is not permission to create an identity.
match read_config_preserve_empty(store, &self.save_file_path()).await {
Ok(data) => {
let previous = Self::decode_file(&data)?;
previous.validate_artifact_configuration()?;
if previous.artifact_backup_retained && !self.artifact_backup_retained {
return Err(Error::other("artifact backup retention cannot be removed"));
}
// Deletion removes this record before recreation. A caller-supplied
// incarnation cannot turn an existing bucket into a new generation.
if previous.artifact_base_prefix != self.artifact_base_prefix
|| (previous.artifact_base_prefix.is_some() && previous.bucket_incarnation_id != self.bucket_incarnation_id)
{
return Err(Error::other("artifact bucket identity is immutable"));
}
}
Err(Error::ConfigNotFound) => {}
Err(error) => return Err(error),
}
Ok(())
}
fn parse_policy_config(&mut self) -> Result<()> {
if !self.policy_config_json.is_empty() {
self.policy_config = Some(serde_json::from_slice(&self.policy_config_json)?);
@@ -1298,7 +1199,6 @@ impl BucketMetadata {
/// `rustfs_bucket_metadata_parse_failed_total`, reflected in
/// `rustfs_bucket_metadata_unparsable_current`, and logged at error level.
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
self.validate_artifact_configuration()?;
if let Err(e) = self.parse_policy_config() {
tracing::warn!(
event = "bucket_metadata_parse_failed",
@@ -1564,10 +1464,6 @@ impl BucketMetadata {
}
}
fn artifact_versioning_enabled(config: &VersioningConfiguration) -> bool {
config.enabled() && !config.exclude_folders.unwrap_or(false) && config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
}
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
let data = match read_config_preserve_empty(api, &path).await {
@@ -1664,7 +1560,9 @@ async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketM
let data = read_config(api, &file_path).await?;
let bm = BucketMetadata::decode_file(&data)?;
BucketMetadata::check_header(&data)?;
let bm = BucketMetadata::unmarshal(&data[4..])?;
Ok(bm)
}
@@ -1688,98 +1586,6 @@ where
mod test {
use super::*;
#[test]
fn artifact_metadata_roundtrip_and_corrupt_prefix_rejection() {
let mut metadata = BucketMetadata::new("artifacts");
metadata.artifact_base_prefix = Some("images/".to_owned());
let encoded = metadata.marshal_msg().expect("encode artifact metadata");
let decoded = BucketMetadata::unmarshal(&encoded).expect("decode artifact metadata");
assert_eq!(decoded.artifact_base_prefix, metadata.artifact_base_prefix, "ARTIFACT_METADATA_ROUNDTRIP");
assert_eq!(decoded.bucket_incarnation_id, metadata.bucket_incarnation_id);
let legacy = BucketMetadata::new("ordinary")
.marshal_msg()
.expect("encode ordinary metadata");
assert!(
BucketMetadata::unmarshal(&legacy)
.expect("decode ordinary metadata")
.artifact_base_prefix
.is_none()
);
for prefix in ["", "../", "outputs", "a b/"] {
let mut invalid = Vec::new();
rmp::encode::write_map_len(&mut invalid, 1).expect("encode map");
rmp::encode::write_str(&mut invalid, "ArtifactBasePrefix").expect("encode field");
rmp::encode::write_str(&mut invalid, prefix).expect("encode invalid prefix");
assert!(BucketMetadata::unmarshal(&invalid).is_err(), "ARTIFACT_CORRUPT_PREFIX");
}
}
#[test]
fn artifact_backup_retention_marker_rejects_false_and_duplicates() {
for count in [1, 2] {
let mut bytes = Vec::new();
rmp::encode::write_map_len(&mut bytes, count).unwrap();
for _ in 0..count {
rmp::encode::write_str(&mut bytes, "ArtifactBackupRetained").unwrap();
rmp::encode::write_bool(&mut bytes, count == 2).unwrap();
}
assert!(BucketMetadata::unmarshal(&bytes).is_err(), "O02P3_INVALID_RETENTION_MARKER");
}
}
#[test]
fn artifact_backup_retention_requires_matching_header_and_identity() {
let mut metadata = BucketMetadata::new("retained");
metadata.bucket_incarnation_id = Uuid::new_v4();
metadata.bucket_creation_committed = true;
metadata.artifact_base_prefix = Some("outputs/".to_owned());
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
let file = |metadata: &BucketMetadata, version: u16| {
let mut bytes = Vec::new();
bytes.extend_from_slice(&BUCKET_METADATA_FORMAT.to_le_bytes());
bytes.extend_from_slice(&version.to_le_bytes());
bytes.extend_from_slice(&metadata.marshal_msg().unwrap());
bytes
};
assert!(BucketMetadata::decode_file(&file(&metadata, 1)).is_ok(), "O02P3_ORDINARY_V1_COMPATIBLE");
assert!(BucketMetadata::decode_file(&file(&metadata, 2)).is_err(), "O02P3_MARKER_REQUIRED");
metadata.artifact_backup_retained = true;
assert!(
BucketMetadata::decode_file(&file(&metadata, 1)).is_err(),
"O02P3_PROTECTION_CANNOT_DOWNGRADE_HEADER"
);
assert!(
BucketMetadata::decode_file(&file(&metadata, 2))
.unwrap()
.artifact_backup_retained,
"O02P3_PROTECTED_V2_ROUNDTRIP"
);
metadata.artifact_base_prefix = None;
assert!(
BucketMetadata::decode_file(&file(&metadata, 2)).is_err(),
"O02P3_PROTECTION_REQUIRES_MANAGED_BUCKET"
);
}
#[test]
fn artifact_versioning_cannot_be_disabled_or_excluded() {
let mut metadata = BucketMetadata::new("artifacts");
metadata.artifact_base_prefix = Some("images/".to_owned());
for xml in [
"",
"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>",
"<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>",
"<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>images/</Prefix></ExcludedPrefixes></VersioningConfiguration>",
] {
assert!(
metadata
.update_config(BUCKET_VERSIONING_CONFIG, xml.as_bytes().to_vec())
.is_err(),
"ARTIFACT_VERSIONING_REQUIRED"
);
}
}
/// rustfs/backlog#1734: `StorageError::clone` rebuilds I/O errors from
/// their text. The unreadable-config refusal must stay typed across that
/// clone, or a cloned error stops mapping to its retryable S3 response.
+6 -36
View File
@@ -293,10 +293,8 @@ pub(super) fn get_bucket_metadata_sys() -> Result<Arc<RwLock<BucketMetadataSys>>
pub async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<()> {
let sys = get_bucket_metadata_sys()?;
let lock = sys.write().await;
if bucket != bm.name {
return Err(Error::other("bucket metadata identity mismatch"));
}
lock.set_validated(bm).await
lock.set(bucket, Arc::new(bm)).await;
Ok(())
}
pub async fn reload_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<()> {
@@ -587,15 +585,6 @@ pub(crate) async fn get_cached_bucket_incarnation_id_in(
sys.get_bucket_incarnation_id(bucket).await
}
pub(crate) async fn get_bucket_incarnation_metadata_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<Arc<BucketMetadata>> {
let sys = bucket_metadata_sys_of(ctx)?;
let sys = sys.read().await.clone();
sys.get_bucket_incarnation_metadata(bucket).await
}
pub(crate) async fn set_bucket_metadata_in(ctx: &crate::runtime::instance::InstanceContext, bm: BucketMetadata) -> Result<()> {
let sys = bucket_metadata_sys_of(ctx)?;
let lock = sys.read().await;
@@ -2143,16 +2132,7 @@ impl BucketMetadataSys {
}
}
pub(crate) async fn set_validated(&self, mut bm: BucketMetadata) -> Result<()> {
if bm.artifact_base_prefix.is_some() {
bm.parse_all_configs()?;
}
bm.validate_artifact_update(self.object_store()).await?;
self.set(bm.name.clone(), Arc::new(bm)).await;
Ok(())
}
async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
if !is_meta_bucketname(&bucket) {
let publish_lock = self.metadata_publish_lock(&bucket);
let _publish_guard = publish_lock.lock().await;
@@ -2167,11 +2147,6 @@ impl BucketMetadataSys {
}
}
#[cfg(any(test, feature = "test-util"))]
pub async fn set_for_test(&self, bucket: String, bm: Arc<BucketMetadata>) {
self.set(bucket, bm).await;
}
/// Remove a bucket's cached metadata from the in-memory map.
///
/// Returns `true` if an entry was present. Reserved meta buckets are ignored.
@@ -2676,16 +2651,12 @@ impl BucketMetadataSys {
}
async fn get_bucket_incarnation_id(&self, bucket: &str) -> Result<Uuid> {
Ok(self.get_bucket_incarnation_metadata(bucket).await?.bucket_incarnation_id)
}
async fn get_bucket_incarnation_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
let authority = self.get_metadata_authority(bucket).await?;
if let BucketMetadataAuthority::Authoritative(metadata) = &authority
&& metadata.bucket_incarnation_sidecar
&& !metadata.bucket_incarnation_id.is_nil()
{
return Ok(Arc::clone(metadata));
return Ok(metadata.bucket_incarnation_id);
}
match authority {
@@ -2694,7 +2665,7 @@ impl BucketMetadataSys {
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
Ok(metadata)
Ok(metadata.bucket_incarnation_id)
}
BucketMetadataAuthority::Authoritative(_) | BucketMetadataAuthority::Fabricated => {
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
@@ -3022,8 +2993,7 @@ impl BucketMetadataSys {
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
)
.await?;
if persisted && (metadata.lock_enabled || metadata.artifact_base_prefix.is_some()) && !metadata.bucket_creation_committed
{
if persisted && metadata.lock_enabled && !metadata.bucket_creation_committed {
return Err(Error::ErasureWriteQuorum);
}
if persisted {
-1
View File
@@ -14,7 +14,6 @@
// #730: bucket subsystems still contain staged ECStore migration code.
pub mod artifact;
pub mod bandwidth;
pub mod bucket_target_sys;
pub mod config_parse_mode;
+12 -13
View File
@@ -235,8 +235,6 @@ impl QuotaChecker {
Ok(updated_at)
}
/// Return quota configuration with optional scanner usage, independently
/// of the usage requirements enforced by write admission.
pub async fn get_quota_stats(&self, bucket: &str) -> Result<(BucketQuota, Option<u64>), QuotaError> {
// If bucket doesn't exist, return ConfigNotFound error
if !self.bucket_exists(bucket).await {
@@ -246,13 +244,18 @@ impl QuotaChecker {
}
let quota = self.get_quota_config(bucket).await?;
let current_usage = self.report_only_usage(bucket).await;
let current_usage = if quota.uses_durable_reservations() {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
Ok((quota, current_usage))
}
/// Best-effort usage for statistics and durable admission reports.
/// Missing scanner coverage is unknown, not an error or a zero-sized bucket.
/// Scanner usage for a bucket the reservation ledger admits is reporting
/// input only, so unavailable memory usage is reported as "unknown" and counted
/// instead of becoming an error the write path would fail on.
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
// The plain lookup: a report must not wait for an early snapshot reload.
let usage = get_bucket_usage_memory(bucket).await;
@@ -264,7 +267,7 @@ impl QuotaChecker {
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
"quota usage report degraded"
"durable quota usage report degraded"
);
}
usage
@@ -739,12 +742,8 @@ mod tests {
1,
"fail-closed admission takes the early-reload lookup"
);
let (_, usage) = checker
.get_quota_stats(&legacy)
.await
.expect("legacy quota statistics are reporting only");
assert_eq!(usage, None);
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 1);
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
@@ -895,7 +894,7 @@ mod tests {
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
.await
.expect("newer quota save");
sys.read().await.set_for_test(bucket.clone(), stale).await;
sys.read().await.set(bucket.clone(), stale).await;
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
@@ -245,86 +245,3 @@ async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
stop_reporting_fixture(&store).await;
}
#[tokio::test]
#[serial]
async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot() {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
for configuration in ["unconfigured", "unlimited", "snapshot"] {
for source in ["missing", "legacy", "incomplete", "observed", "complete", "empty"] {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-statistics-{}", Uuid::new_v4().simple());
match configuration {
"unconfigured" => store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket without a quota configuration"),
"unlimited" => {
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
)
.await;
}
"snapshot" => create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await,
_ => unreachable!(),
}
// An unregistered checker keeps legacy migration out of the
// assertion: reporting must work before a protocol upgrade.
let metadata_sys = BucketMetadataSys::new(store.clone());
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
metadata_sys.set_for_test(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let statistics = checker.get_quota_stats(&bucket).await;
let early_reloads = crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket);
let missing_bucket = format!("missing-{}", Uuid::new_v4().simple());
let missing_statistics = checker.get_quota_stats(&missing_bucket).await;
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("fixture cache cleanup");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
stop_reporting_fixture(&store).await;
let (quota, usage) = statistics.unwrap_or_else(|error| {
panic!("configuration={configuration}, source={source}: quota statistics must remain available: {error}")
});
assert_eq!(quota.quota, (configuration == "snapshot").then_some(4096));
assert_eq!(
usage,
(snapshot_source == "complete").then_some(size),
"configuration={configuration}, source={source}: only known usage is numeric"
);
assert_eq!(early_reloads, 0, "read-only statistics must not force an early snapshot reload");
assert!(
matches!(missing_statistics, Err(QuotaError::ConfigNotFound { bucket }) if bucket == missing_bucket),
"reporting must still reject a missing bucket"
);
}
}
}
#[tokio::test]
async fn quota_statistics_preserve_configuration_errors() {
let (_dirs, store, _) = isolated_durable_quota_store().await;
let bucket = format!("quota-invalid-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let metadata_sys = BucketMetadataSys::new(store.clone());
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
metadata_sys.set_for_test(bucket.clone(), metadata).await;
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
let statistics = checker.get_quota_stats(&bucket).await;
stop_reporting_fixture(&store).await;
assert!(
matches!(statistics, Err(QuotaError::InvalidConfig { .. })),
"unavailable reporting must not hide malformed quota configuration"
);
}
@@ -0,0 +1,682 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use crate::disk::background_memory::BackgroundBuffer;
use std::io::{self, IoSlice};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker, ready};
use tokio::io::{AsyncRead, AsyncWrite, DuplexStream, ReadBuf};
pub struct BackgroundPipeReader {
inner: PipeEnd,
}
pub struct BackgroundPipeWriter {
inner: PipeEnd,
}
impl BackgroundPipeWriter {
/// Capture only the payload owner, without retaining node or physical units.
pub fn memory_budget(&self) -> Option<Arc<crate::disk::BackgroundMemoryBudget>> {
match &self.inner {
PipeEnd::Foreground(_) => None,
PipeEnd::Background(state) => state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.buffer
.memory_budget(),
}
}
}
enum PipeEnd {
Foreground(DuplexStream),
Background(Arc<Mutex<PipeState>>),
}
// The pipe lock may reserve memory. The memory budget never locks a pipe.
struct PipeState {
buffer: BackgroundBuffer,
read_offset: usize,
readable: usize,
capacity: usize,
reader_closed: bool,
writer_closed: bool,
admission_failure: Option<io::ErrorKind>,
read_waker: Option<Waker>,
write_waker: Option<Waker>,
}
/// Capture only memory ownership while the request is admitted. Payload storage
/// is reserved and initialized once, before the first nonempty background write.
pub fn background_pipe(capacity: usize) -> (BackgroundPipeReader, BackgroundPipeWriter) {
let capacity = capacity.max(1);
let memory = crate::disk::current_resource_unit().and_then(|resources| resources.memory_budget());
match memory {
Some(memory) => {
let state = Arc::new(Mutex::new(PipeState {
buffer: BackgroundBuffer::new(Some(memory)),
read_offset: 0,
readable: 0,
capacity,
reader_closed: false,
writer_closed: false,
admission_failure: None,
read_waker: None,
write_waker: None,
}));
(
BackgroundPipeReader {
inner: PipeEnd::Background(state.clone()),
},
BackgroundPipeWriter {
inner: PipeEnd::Background(state),
},
)
}
None => {
let (reader, writer) = tokio::io::duplex(capacity);
(
BackgroundPipeReader {
inner: PipeEnd::Foreground(reader),
},
BackgroundPipeWriter {
inner: PipeEnd::Foreground(writer),
},
)
}
}
}
fn advance_ring(offset: usize, count: usize, capacity: usize) -> usize {
let to_end = capacity - offset;
if count < to_end { offset + count } else { count - to_end }
}
fn poll_background_read(state: &Mutex<PipeState>, cx: &mut Context<'_>, output: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if output.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let mut state = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if state.readable == 0 {
if let Some(kind) = state.admission_failure.take() {
drop(state);
return Poll::Ready(Err(io::Error::new(kind, "background pipe storage admission failed")));
}
if state.writer_closed {
return Poll::Ready(Ok(()));
}
let replaced = state.read_waker.replace(cx.waker().clone());
drop(state);
drop(replaced);
return Poll::Pending;
}
let length = state.readable.min(output.remaining());
let first = length.min(state.capacity - state.read_offset);
output.put_slice(&state.buffer.as_slice()[state.read_offset..state.read_offset + first]);
output.put_slice(&state.buffer.as_slice()[..length - first]);
state.read_offset = advance_ring(state.read_offset, length, state.capacity);
state.readable -= length;
let wake = state.write_waker.take();
drop(state);
if let Some(wake) = wake {
wake.wake();
}
Poll::Ready(Ok(()))
}
fn poll_background_write(state: &Mutex<PipeState>, cx: &mut Context<'_>, input: &[IoSlice<'_>]) -> Poll<io::Result<usize>> {
let mut state = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if state.reader_closed || state.writer_closed {
return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
}
if input.iter().all(|part| part.is_empty()) {
return Poll::Ready(Ok(0));
}
let available = state.capacity - state.readable;
if available == 0 {
let replaced = state.write_waker.replace(cx.waker().clone());
drop(state);
drop(replaced);
return Poll::Pending;
}
let capacity = state.capacity;
if let Err(error) = state.buffer.ensure_len(capacity) {
// Legacy walk completion may suppress ordinary producer errors. A new
// pipe admission failure must also reach the reader instead of EOF.
state.admission_failure = Some(error.kind());
state.writer_closed = true;
let wake = state.read_waker.take();
drop(state);
if let Some(wake) = wake {
wake.wake();
}
return Poll::Ready(Err(error));
}
let mut offset = advance_ring(state.read_offset, state.readable, capacity);
let mut written = 0;
for part in input {
let length = part.len().min(available - written);
let first = length.min(capacity - offset);
state.buffer.as_mut_slice()[offset..offset + first].copy_from_slice(&part[..first]);
state.buffer.as_mut_slice()[..length - first].copy_from_slice(&part[first..length]);
offset = advance_ring(offset, length, capacity);
written += length;
if written == available {
break;
}
}
state.readable += written;
let wake = state.read_waker.take();
drop(state);
if let Some(wake) = wake {
wake.wake();
}
Poll::Ready(Ok(written))
}
impl AsyncRead for BackgroundPipeReader {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, output: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().inner {
PipeEnd::Foreground(reader) => Pin::new(reader).poll_read(cx, output),
PipeEnd::Background(state) => {
let coop = ready!(tokio::task::coop::poll_proceed(cx));
let result = poll_background_read(state, cx, output);
if result.is_ready() {
coop.made_progress();
}
result
}
}
}
}
impl AsyncWrite for BackgroundPipeWriter {
fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, input: &[u8]) -> Poll<io::Result<usize>> {
match &mut self.get_mut().inner {
PipeEnd::Foreground(writer) => Pin::new(writer).poll_write(cx, input),
PipeEnd::Background(state) => {
let coop = ready!(tokio::task::coop::poll_proceed(cx));
let result = poll_background_write(state, cx, &[IoSlice::new(input)]);
if result.is_ready() {
coop.made_progress();
}
result
}
}
}
fn poll_write_vectored(self: Pin<&mut Self>, cx: &mut Context<'_>, input: &[IoSlice<'_>]) -> Poll<io::Result<usize>> {
match &mut self.get_mut().inner {
PipeEnd::Foreground(writer) => Pin::new(writer).poll_write_vectored(cx, input),
PipeEnd::Background(state) => {
let coop = ready!(tokio::task::coop::poll_proceed(cx));
let result = poll_background_write(state, cx, input);
if result.is_ready() {
coop.made_progress();
}
result
}
}
}
fn is_write_vectored(&self) -> bool {
match &self.inner {
PipeEnd::Foreground(writer) => writer.is_write_vectored(),
PipeEnd::Background(_) => true,
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().inner {
PipeEnd::Foreground(writer) => Pin::new(writer).poll_flush(cx),
PipeEnd::Background(_) => Poll::Ready(Ok(())),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().inner {
PipeEnd::Foreground(writer) => Pin::new(writer).poll_shutdown(cx),
PipeEnd::Background(state) => {
let mut state = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.writer_closed = true;
let wake = state.read_waker.take();
drop(state);
if let Some(wake) = wake {
wake.wake();
}
Poll::Ready(Ok(()))
}
}
}
}
impl Drop for BackgroundPipeReader {
fn drop(&mut self) {
if let PipeEnd::Background(state) = &self.inner {
let mut state = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.reader_closed = true;
let wake = state.write_waker.take();
let cancelled = state.read_waker.take();
drop(state);
drop(cancelled);
if let Some(wake) = wake {
wake.wake();
}
}
}
}
impl Drop for BackgroundPipeWriter {
fn drop(&mut self) {
if let PipeEnd::Background(state) = &self.inner {
let mut state = state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.writer_closed = true;
let wake = state.read_waker.take();
let cancelled = state.write_waker.take();
drop(state);
drop(cancelled);
if let Some(wake) = wake {
wake.wake();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disk::BackgroundMemoryBudget;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub(super) fn budget(capacity: usize) -> Arc<BackgroundMemoryBudget> {
let budget = BackgroundMemoryBudget::new();
budget.set_sampled_limit(u64::try_from(capacity).expect("test capacity fits u64"), Duration::from_secs(30));
budget
}
pub(super) fn local_pipe(
memory: &Arc<BackgroundMemoryBudget>,
capacity: usize,
) -> (BackgroundPipeReader, BackgroundPipeWriter) {
let state = Arc::new(Mutex::new(PipeState {
buffer: BackgroundBuffer::new(Some(memory.clone())),
read_offset: 0,
readable: 0,
capacity,
reader_closed: false,
writer_closed: false,
admission_failure: None,
read_waker: None,
write_waker: None,
}));
(
BackgroundPipeReader {
inner: PipeEnd::Background(state.clone()),
},
BackgroundPipeWriter {
inner: PipeEnd::Background(state),
},
)
}
pub(super) fn write_now(writer: &mut (impl AsyncWrite + Unpin), bytes: &[u8]) -> Poll<io::Result<usize>> {
Pin::new(writer).poll_write(&mut Context::from_waker(Waker::noop()), bytes)
}
pub(super) fn read_now(reader: &mut (impl AsyncRead + Unpin), bytes: &mut [u8]) -> Poll<io::Result<usize>> {
let mut output = ReadBuf::new(bytes);
match Pin::new(reader).poll_read(&mut Context::from_waker(Waker::noop()), &mut output) {
Poll::Ready(Ok(())) => Poll::Ready(Ok(output.filled().len())),
Poll::Ready(Err(error)) => Poll::Ready(Err(error)),
Poll::Pending => Poll::Pending,
}
}
#[test]
fn fixed_ring_allocates_lazily_once_and_keeps_last_owner_charged() {
for capacity in [1, 7, 64 * 1024, 1024 * 1024] {
let memory = budget(capacity);
let (mut reader, mut writer) = local_pipe(&memory, capacity);
assert_eq!(memory.snapshot().used, 0);
assert!(matches!(write_now(&mut writer, &[]), Poll::Ready(Ok(0))));
assert_eq!(memory.snapshot().used, 0);
assert!(matches!(write_now(&mut writer, b"x"), Poll::Ready(Ok(1))));
assert_eq!(memory.snapshot().used, capacity as u64);
memory.set_sampled_limit(0, Duration::ZERO);
let mut output = [0];
assert!(matches!(read_now(&mut reader, &mut output), Poll::Ready(Ok(1))));
assert_eq!(output, *b"x");
assert!(matches!(write_now(&mut writer, b"y"), Poll::Ready(Ok(1))));
drop(writer);
assert_eq!(memory.snapshot().used, capacity as u64, "reader still owns the real ring");
assert!(matches!(read_now(&mut reader, &mut output), Poll::Ready(Ok(1))));
assert_eq!(output, *b"y");
assert!(matches!(read_now(&mut reader, &mut output), Poll::Ready(Ok(0))));
assert_eq!(memory.snapshot().used, capacity as u64, "draining does not destroy allocation ownership");
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
}
#[test]
fn admission_error_reaches_writer_and_reader_once_without_payload() {
for sample in [None, Some((6, Duration::from_secs(30))), Some((7, Duration::ZERO))] {
let memory = BackgroundMemoryBudget::new();
if let Some((limit, validity)) = sample {
memory.set_sampled_limit(limit, validity);
}
let (mut reader, mut writer) = local_pipe(&memory, 7);
let error = match write_now(&mut writer, b"x") {
Poll::Ready(Err(e)) => e,
_ => panic!("reject before copying first payload"),
};
assert!(
crate::disk::background_memory::is_memory_pressure(&error),
"writer retains the original budget marker"
);
assert_eq!(memory.snapshot().used, 0);
let error = match read_now(&mut reader, &mut [0]) {
Poll::Ready(Err(e)) => e,
_ => panic!("reader must see a pipe admission failure"),
};
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert!(matches!(read_now(&mut reader, &mut [0]), Poll::Ready(Ok(0))), "failure is delivered once");
assert!(matches!(write_now(&mut writer,b"x"),Poll::Ready(Err(e)) if e.kind()==io::ErrorKind::BrokenPipe));
drop((reader, writer));
assert_eq!(memory.snapshot().used, 0);
}
}
#[test]
fn allocation_overflow_is_a_reader_failure_without_a_leaked_reservation() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(u64::MAX, Duration::from_secs(30));
let (mut reader, mut writer) = local_pipe(&memory, usize::MAX);
assert!(matches!(write_now(&mut writer,b"x"),Poll::Ready(Err(e)) if e.kind()==io::ErrorKind::OutOfMemory));
assert_eq!(memory.snapshot().used, 0);
assert!(matches!(read_now(&mut reader,&mut[0]),Poll::Ready(Err(e)) if e.kind()==io::ErrorKind::OutOfMemory));
}
#[test]
fn scalar_and_vectored_wrap_match_a_reference_queue() {
use std::collections::VecDeque;
for capacity in [1, 3, 7, 31] {
let memory = budget(capacity);
let (mut reader, mut writer) = local_pipe(&memory, capacity);
let mut expected = VecDeque::new();
let mut next = 0u8;
for turn in 0..300 {
let input: Vec<_> = (0..(turn % 11 + 1))
.map(|_| {
let byte = next;
next = next.wrapping_add(1);
byte
})
.collect();
let available = capacity - expected.len();
let written = if turn % 2 == 0 {
write_now(&mut writer, &input)
} else {
let split = input.len() / 2;
Pin::new(&mut writer).poll_write_vectored(
&mut Context::from_waker(Waker::noop()),
&[
io::IoSlice::new(&[]),
io::IoSlice::new(&input[..split]),
io::IoSlice::new(&input[split..]),
],
)
};
if available == 0 {
assert!(written.is_pending());
} else {
let accepted = input.len().min(available);
assert!(matches!(written,Poll::Ready(Ok(n)) if n==accepted));
expected.extend(&input[..accepted]);
}
let mut output = vec![0; turn % 5 + 1];
let count = output.len().min(expected.len());
assert!(matches!(read_now(&mut reader,&mut output),Poll::Ready(Ok(n)) if n==count));
for byte in &output[..count] {
assert_eq!(Some(*byte), expected.pop_front());
}
assert_eq!(memory.snapshot().used, capacity as u64);
}
drop(writer);
let mut output = vec![0; capacity];
let count = expected.len();
assert!(matches!(read_now(&mut reader,&mut output),Poll::Ready(Ok(n)) if n==count));
assert_eq!(&output[..count], expected.into_iter().collect::<Vec<_>>().as_slice());
assert!(matches!(read_now(&mut reader, &mut output), Poll::Ready(Ok(0))));
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn reader_drop_wakes_blocked_writer_and_writer_drop_wakes_empty_reader() {
let memory = budget(7);
let (reader, mut writer) = local_pipe(&memory, 7);
writer.write_all(b"1234567").await.expect("fill the ring");
let task = tokio::spawn(async move { writer.write_all(b"blocked").await });
tokio::task::yield_now().await;
assert!(!task.is_finished());
drop(reader);
let error = tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("reader drop wakes writer")
.expect("writer task joins")
.expect_err("closed reader is BrokenPipe");
assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
assert_eq!(memory.snapshot().used, 0);
let (mut reader, writer) = local_pipe(&memory, 7);
let task = tokio::spawn(async move { reader.read(&mut [0]).await });
tokio::task::yield_now().await;
assert!(!task.is_finished());
drop(writer);
assert_eq!(
tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("writer drop wakes reader")
.expect("reader task joins")
.expect("clean EOF"),
0
);
}
#[tokio::test]
async fn read_wakes_a_full_writer_without_changing_byte_limit() {
let memory = budget(7);
let (mut reader, mut writer) = local_pipe(&memory, 7);
writer.write_all(b"1234567").await.expect("fill initial byte limit");
let task = tokio::spawn(async move {
writer.write_all(b"x").await.expect("resume after one byte is read");
writer
});
tokio::task::yield_now().await;
assert!(!task.is_finished());
let mut first = [0];
reader.read_exact(&mut first).await.expect("read one byte");
let writer = tokio::time::timeout(Duration::from_secs(1), task)
.await
.expect("read wakes writer")
.expect("writer joins");
assert_eq!(&first, b"1");
drop(writer);
let mut all = Vec::new();
reader.read_to_end(&mut all).await.expect("drain wrapped bytes");
assert_eq!(all, b"234567x");
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn shutdown_drains_then_eof_and_cancelled_writes_accept_nothing() {
let memory = budget(7);
let (mut reader, mut writer) = local_pipe(&memory, 7);
writer
.write_all(b"1234567")
.await
.expect("fill before cancelling a blocked write");
{
let blocked = writer.write(b"lost");
tokio::pin!(blocked);
assert!(futures::poll!(&mut blocked).is_pending());
}
writer.shutdown().await.expect("shutdown does not discard buffered bytes");
assert!(matches!(write_now(&mut writer,b"x"),Poll::Ready(Err(e)) if e.kind()==io::ErrorKind::BrokenPipe));
let mut out = Vec::new();
reader.read_to_end(&mut out).await.expect("drain after shutdown");
assert_eq!(out, b"1234567");
drop(reader);
assert_eq!(memory.snapshot().used, 7, "writer still holds the allocation");
drop(writer);
assert_eq!(memory.snapshot().used, 0);
}
#[test]
fn foreground_keeps_native_duplex_semantics() {
let (reader, mut writer) = background_pipe(7);
assert!(matches!(write_now(&mut writer, b"12345678"), Poll::Ready(Ok(7))));
assert!(write_now(&mut writer, b"x").is_pending());
drop(reader);
assert!(matches!(write_now(&mut writer,b"x"),Poll::Ready(Err(e)) if e.kind()==io::ErrorKind::BrokenPipe));
let (mut reader, writer) = background_pipe(7);
drop(writer);
assert!(matches!(read_now(&mut reader, &mut [0]), Poll::Ready(Ok(0))));
}
#[test]
fn cancelled_reader_leaves_the_real_ring_charged_until_writer_drop() {
let memory = budget(7);
let (reader, mut writer) = local_pipe(&memory, 7);
assert!(matches!(write_now(&mut writer, b"x"), Poll::Ready(Ok(1))));
drop(reader);
assert_eq!(memory.snapshot().used, 7, "the surviving writer still owns the ring allocation");
assert!(matches!(write_now(&mut writer,b"y"),Poll::Ready(Err(e)) if e.kind()==io::ErrorKind::BrokenPipe));
drop(writer);
assert_eq!(memory.snapshot().used, 0);
}
#[test]
fn runtime_shutdown_releases_queued_writer_but_not_the_surviving_reader() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build isolated runtime");
let memory = budget(7);
let (reader, mut writer) = local_pipe(&memory, 7);
runtime.block_on(async {
writer.write_all(b"x").await.expect("allocate ring before runtime shutdown");
tokio::spawn(async move {
let _writer = writer;
std::future::pending::<()>().await;
});
tokio::task::yield_now().await;
});
drop(runtime);
assert_eq!(memory.snapshot().used, 7, "reader holds the actual allocation after runtime cancellation");
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn ready_operations_keep_tokio_cooperative_yields() {
let memory = budget(7);
let (_reader, mut writer) = local_pipe(&memory, 7);
let observed = Arc::new(AtomicUsize::new(0));
let flag = observed.clone();
let task = tokio::spawn(async move {
flag.store(1, Ordering::SeqCst);
});
for _ in 0..1000 {
assert_eq!(writer.write(&[]).await.expect("empty operation remains successful"), 0);
}
assert_eq!(
observed.load(Ordering::SeqCst),
1,
"ready pipe operations must not starve other ready tasks"
);
task.await.expect("observer task joins");
}
}
#[cfg(test)]
mod waker_tests {
use super::*;
use std::sync::{
Weak,
atomic::{AtomicUsize, Ordering},
};
use std::task::Wake;
struct LockCheck {
state: Weak<Mutex<PipeState>>,
wakes: Arc<AtomicUsize>,
drops: Arc<AtomicUsize>,
}
impl LockCheck {
fn unlocked(&self) {
if let Some(state) = self.state.upgrade() {
assert!(state.try_lock().is_ok(), "waker code must run outside the pipe lock");
}
}
}
impl Wake for LockCheck {
fn wake(self: Arc<Self>) {
self.unlocked();
self.wakes.fetch_add(1, Ordering::SeqCst);
}
}
impl Drop for LockCheck {
fn drop(&mut self) {
self.unlocked();
self.drops.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn replaced_and_woken_reader_and_writer_wakers_run_outside_the_lock() {
for waiting_reader in [true, false] {
let memory = super::tests::budget(7);
let (mut reader, mut writer) = super::tests::local_pipe(&memory, 7);
let PipeEnd::Background(state) = &reader.inner else { panic!("background fixture") };
let weak = Arc::downgrade(state);
let wakes = Arc::new(AtomicUsize::new(0));
let drops = Arc::new(AtomicUsize::new(0));
if !waiting_reader {
assert!(matches!(super::tests::write_now(&mut writer, b"1234567"), Poll::Ready(Ok(7))));
}
for _ in 0..2 {
let waker = Waker::from(Arc::new(LockCheck {
state: weak.clone(),
wakes: wakes.clone(),
drops: drops.clone(),
}));
let mut cx = Context::from_waker(&waker);
if waiting_reader {
let mut bytes = [0];
assert!(
Pin::new(&mut reader)
.poll_read(&mut cx, &mut ReadBuf::new(&mut bytes))
.is_pending()
);
} else {
assert!(Pin::new(&mut writer).poll_write(&mut cx, b"x").is_pending());
}
drop(waker);
}
assert_eq!(drops.load(Ordering::SeqCst), 1, "the replaced waker has been dropped");
if waiting_reader {
assert!(matches!(super::tests::write_now(&mut writer, b"x"), Poll::Ready(Ok(1))));
} else {
assert!(matches!(super::tests::read_now(&mut reader, &mut [0]), Poll::Ready(Ok(1))));
}
assert_eq!(wakes.load(Ordering::SeqCst), 1);
assert_eq!(drops.load(Ordering::SeqCst), 2);
}
}
}
@@ -0,0 +1,586 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use crate::disk::background_memory::{BackgroundMemoryBudget, Reservation};
use bytes::{Bytes, BytesMut};
use futures::Stream;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, ReadBuf};
use tokio_util::io::ReaderStream;
struct ReservedReadBytes {
// Free each payload view before releasing its shared allocation receipt.
bytes: Bytes,
_reservation: Arc<Reservation>,
}
impl AsRef<[u8]> for ReservedReadBytes {
fn as_ref(&self) -> &[u8] {
&self.bytes
}
}
struct ReaderBuffer {
// These initialized bytes are spare storage, not unconsumed source data.
bytes: BytesMut,
reservation: Option<Arc<Reservation>>,
budget: Arc<BackgroundMemoryBudget>,
capacity: usize,
}
impl ReaderBuffer {
fn new(budget: Arc<BackgroundMemoryBudget>, capacity: usize) -> Self {
Self {
bytes: BytesMut::new(),
reservation: None,
budget,
capacity: capacity.max(1),
}
}
fn clear(&mut self) {
self.bytes = BytesMut::new();
self.reservation = None;
}
fn ensure_readable(&mut self) -> io::Result<()> {
if !self.bytes.is_empty() {
return Ok(());
}
if self.reservation.is_some() {
if self.bytes.try_reclaim(self.capacity) {
self.bytes.resize(self.capacity, 0);
return Ok(());
}
// A live output owns this allocation. Drop our empty view before
// its receipt, then reserve independent replacement storage.
self.clear();
}
let reservation = self.budget.reserve(self.capacity)?;
let mut bytes = Vec::new();
bytes
.try_reserve_exact(self.capacity)
.map_err(|_| io::ErrorKind::OutOfMemory)?;
bytes.resize(self.capacity, 0);
// Fresh ordinary Bytes is unique and spans the complete allocation.
// Unlike from_owner output, this conversion cannot require a copy.
let bytes = Bytes::from(bytes.into_boxed_slice())
.try_into_mut()
.map_err(|_| io::Error::other("fresh reader storage was not unique"))?;
self.bytes = bytes;
self.reservation = Some(Arc::new(reservation));
Ok(())
}
fn take_bytes(&mut self, length: usize) -> io::Result<Bytes> {
let reservation = self
.reservation
.as_ref()
.cloned()
.ok_or_else(|| io::Error::other("reader storage has no memory reservation"))?;
Ok(Bytes::from_owner(ReservedReadBytes {
bytes: self.bytes.split_to(length).freeze(),
_reservation: reservation,
}))
}
}
/// RPC response chunks retain their background memory reservation until the
/// last clone or slice is dropped. Foreground readers use the existing stream.
pub struct BackgroundReaderStream<R> {
inner: ReaderState<R>,
}
enum ReaderState<R> {
Foreground(ReaderStream<R>),
Background { reader: Option<R>, buffer: ReaderBuffer },
}
impl<R: AsyncRead> BackgroundReaderStream<R> {
/// Capture memory ownership inside the admitted scope, before moving the
/// response body out of it. This stream retains no node or device capacity.
pub fn with_capacity(reader: R, capacity: usize) -> Self {
let memory = crate::disk::current_resource_unit().and_then(|resources| resources.memory_budget());
let inner = match memory {
Some(memory) => ReaderState::Background {
reader: Some(reader),
buffer: ReaderBuffer::new(memory, capacity),
},
None => ReaderState::Foreground(ReaderStream::with_capacity(reader, capacity)),
};
Self { inner }
}
}
impl<R: AsyncRead + Unpin> Stream for BackgroundReaderStream<R> {
type Item = io::Result<Bytes>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match &mut this.inner {
ReaderState::Foreground(reader) => Pin::new(reader).poll_next(cx),
ReaderState::Background { reader, buffer } => {
let Some(inner) = reader.as_mut() else {
return Poll::Ready(None);
};
if let Err(error) = buffer.ensure_readable() {
*reader = None;
return Poll::Ready(Some(Err(error)));
}
let mut output = ReadBuf::new(&mut buffer.bytes);
match std::task::ready!(Pin::new(inner).poll_read(cx, &mut output)) {
Ok(()) => {
let length = output.filled().len();
if length == 0 {
buffer.clear();
*reader = None;
Poll::Ready(None)
} else {
let bytes = buffer.take_bytes(length);
if bytes.is_err() {
buffer.clear();
*reader = None;
}
Poll::Ready(Some(bytes))
}
}
Err(error) => {
buffer.clear();
*reader = None;
Poll::Ready(Some(Err(error)))
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
use futures::StreamExt;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
struct ObservedReader {
polls: Arc<AtomicUsize>,
fail: bool,
}
impl AsyncRead for ObservedReader {
fn poll_read(self: Pin<&mut Self>, _cx: &mut Context<'_>, output: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
self.polls.fetch_add(1, Ordering::SeqCst);
if self.fail {
Poll::Ready(Err(io::Error::from(io::ErrorKind::PermissionDenied)))
} else {
output.put_slice(b"data");
Poll::Ready(Ok(()))
}
}
}
#[tokio::test]
async fn background_frames_keep_capacity_until_the_last_slice() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(16, Duration::from_secs(30));
let (resources, gates) = test_resource_units(2);
let mut stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BackgroundReaderStream::with_capacity(io::Cursor::new(b"data"), 16)
})
.await;
assert!(
gates.iter().all(|gate| gate.snapshot().active == 0),
"the stream captures only memory ownership"
);
assert_eq!(memory.snapshot().used, 0, "construction does not allocate payload storage");
let bytes = stream.next().await.expect("first frame").expect("read frame");
assert_eq!(bytes.as_ref(), b"data");
assert_eq!(memory.snapshot().used, 16, "charge full capacity rather than visible length");
let slice = bytes.slice(1..3);
memory.set_sampled_limit(0, Duration::ZERO);
drop(bytes);
drop(stream);
assert_eq!(slice.as_ref(), b"at");
assert_eq!(memory.snapshot().used, 16);
drop(slice);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn background_denial_happens_before_reading_and_never_falls_back() {
for sample in [None, Some((7, Duration::from_secs(30))), Some((8, Duration::ZERO))] {
let memory = BackgroundMemoryBudget::new();
if let Some((limit, validity)) = sample {
memory.set_sampled_limit(limit, validity);
}
let polls = Arc::new(AtomicUsize::new(0));
let (resources, _) = test_resource_units(1);
let mut stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BackgroundReaderStream::with_capacity(
ObservedReader {
polls: polls.clone(),
fail: false,
},
8,
)
})
.await;
let error = stream
.next()
.await
.expect("allocation failure")
.expect_err("background allocation denied");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert_eq!(polls.load(Ordering::SeqCst), 0, "denial cannot consume source bytes");
assert!(stream.next().await.is_none());
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn background_pending_read_keeps_storage_through_shrink_or_cancellation() {
for cancelled in [false, true] {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(16, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let (mut sender, reader) = tokio::io::duplex(4);
let mut stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BackgroundReaderStream::with_capacity(reader, 16)
})
.await;
assert!(futures::poll!(stream.next()).is_pending());
memory.set_sampled_limit(0, Duration::ZERO);
assert_eq!(memory.snapshot().used, 16);
let bytes = if cancelled {
None
} else {
sender.write_all(b"data").await.expect("release pending read");
Some(
stream
.next()
.await
.expect("pending frame")
.expect("existing allocation survives shrink"),
)
};
drop(stream);
if let Some(bytes) = bytes {
assert_eq!(bytes.as_ref(), b"data");
assert_eq!(memory.snapshot().used, 16);
drop(bytes);
}
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn background_read_error_and_eof_release_storage() {
for fail in [false, true] {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(8, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let mut stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
let reader: Box<dyn AsyncRead + Unpin> = if fail {
Box::new(ObservedReader {
polls: Arc::new(AtomicUsize::new(0)),
fail: true,
})
} else {
Box::new(tokio::io::empty())
};
BackgroundReaderStream::with_capacity(reader, 8)
})
.await;
if fail {
let error = stream.next().await.expect("read failure").expect_err("preserve reader error");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
assert!(stream.next().await.is_none());
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn retained_frame_blocks_new_storage_until_released() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(8, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let mut stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BackgroundReaderStream::with_capacity(io::Cursor::new(b"firstonefollowup"), 8)
})
.await;
let first = stream.next().await.expect("first frame").expect("fits exact budget");
assert_eq!(first.as_ref(), b"firstone");
let error = stream
.next()
.await
.expect("next allocation")
.expect_err("held frame exhausts budget");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert_eq!(memory.snapshot().used, 8);
drop(stream);
drop(first);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn consumed_frames_keep_reusable_capacity_until_eof() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(8, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let mut stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BackgroundReaderStream::with_capacity(io::Cursor::new(b"firstonefollowup"), 8)
})
.await;
for expected in [b"firstone", b"followup"] {
let bytes = stream.next().await.expect("next frame").expect("released previous capacity");
assert_eq!(bytes.as_ref(), expected);
assert_eq!(memory.snapshot().used, 8);
drop(bytes);
assert_eq!(memory.snapshot().used, 8, "the reusable allocation remains owned by the stream");
}
assert!(stream.next().await.is_none());
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn foreground_stream_and_unpolled_cancellation_preserve_existing_behavior() {
let mut stream = BackgroundReaderStream::with_capacity(io::Cursor::new(b"foreground"), 4);
assert!(matches!(stream.inner, ReaderState::Foreground(_)));
let mut bytes = Vec::new();
while let Some(chunk) = stream.next().await {
bytes.extend_from_slice(&chunk.expect("foreground chunk"));
}
assert_eq!(bytes, b"foreground");
let memory = BackgroundMemoryBudget::new();
let (resources, _) = test_resource_units(1);
let stream = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BackgroundReaderStream::with_capacity(tokio::io::empty(), 8)
})
.await;
drop(stream);
assert_eq!(memory.snapshot().used, 0);
}
}
#[cfg(test)]
mod reuse_tests {
use super::*;
use bytes::Bytes;
use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration;
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, ReadBuf};
static DATA: [u8; 1024] = [0x37; 1024];
struct ShortReader {
offset: usize,
length: usize,
chunk: usize,
fail_at: Option<usize>,
}
impl AsyncRead for ShortReader {
fn poll_read(mut self: Pin<&mut Self>, _: &mut Context<'_>, out: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
if self.fail_at == Some(self.offset) {
return Poll::Ready(Err(io::ErrorKind::PermissionDenied.into()));
}
let n = (self.length - self.offset).min(self.chunk).min(out.remaining());
out.put_slice(&DATA[self.offset..self.offset + n]);
self.offset += n;
Poll::Ready(Ok(()))
}
}
fn budget(limit: u64) -> Arc<crate::disk::BackgroundMemoryBudget> {
let b = crate::disk::BackgroundMemoryBudget::new();
b.set_sampled_limit(limit, Duration::from_secs(30));
b
}
async fn stream<R: AsyncRead>(
budget: &Arc<crate::disk::BackgroundMemoryBudget>,
reader: R,
capacity: usize,
) -> BackgroundReaderStream<R> {
let (resources, _) = crate::disk::test_resource_units(1);
crate::disk::with_optional_resource_unit(Some(resources.with_memory_budget(budget.clone())), async {
BackgroundReaderStream::with_capacity(reader, capacity)
})
.await
}
#[tokio::test]
async fn complete_consumed_frames_reclaim_the_same_storage_after_expiry() {
let b = budget(64);
let mut s = stream(&b, io::Cursor::new(&DATA[..]), 64).await;
let mut pointer = None;
for _ in 0..16 {
let frame = s.next().await.expect("next full frame").expect("reclaimed backing");
assert_eq!(frame.len(), 64);
assert_eq!(frame.as_ref(), &DATA[..64]);
if let Some(p) = pointer {
assert_eq!(frame.as_ptr(), p);
} else {
pointer = Some(frame.as_ptr());
}
b.set_sampled_limit(0, Duration::ZERO);
drop(frame);
assert_eq!(b.snapshot().used, 64);
}
assert!(s.next().await.is_none());
assert_eq!(b.snapshot().used, 0);
}
#[tokio::test]
async fn short_live_frames_use_disjoint_tail_then_reserve_separate_storage() {
let b = budget(32);
let mut s = stream(
&b,
ShortReader {
offset: 0,
length: 32,
chunk: 4,
fail_at: None,
},
16,
)
.await;
let mut frames: Vec<Bytes> = Vec::new();
for i in 0..4 {
let frame = s.next().await.expect("short frame").expect("remaining initialized tail");
if i != 0 {
assert_eq!(frame.as_ptr(), frames[0].as_ptr().wrapping_add(i * 4));
}
assert_eq!(frame.as_ref(), &DATA[..4]);
frames.push(frame);
assert_eq!(b.snapshot().used, 16);
}
let next = s.next().await.expect("independent frame").expect("second backing fits");
assert_ne!(next.as_ptr(), frames[0].as_ptr());
assert_eq!(b.snapshot().used, 32);
drop(frames);
assert_eq!(b.snapshot().used, 16);
drop(s);
assert_eq!(b.snapshot().used, 16);
drop(next);
assert_eq!(b.snapshot().used, 0);
}
#[tokio::test]
async fn final_slice_prevents_reclaim_and_keeps_old_plus_new_charged() {
let b = budget(32);
let mut s = stream(&b, io::Cursor::new(&DATA[..32]), 16).await;
let first = s.next().await.expect("old frame").expect("old backing");
let last = first.slice(15..);
drop(first);
let next = s.next().await.expect("new frame").expect("second backing fits");
assert_eq!(last.as_ref(), &DATA[..1]);
assert_ne!(next.as_ptr(), last.as_ptr());
assert_eq!(b.snapshot().used, 32);
drop(s);
assert_eq!(b.snapshot().used, 32);
drop(next);
assert_eq!(b.snapshot().used, 16);
drop(last);
assert_eq!(b.snapshot().used, 0);
}
#[tokio::test]
async fn expiry_uses_tail_but_rejects_new_storage_before_consuming_source() {
let b = budget(16);
let mut s = stream(
&b,
ShortReader {
offset: 0,
length: 32,
chunk: 4,
fail_at: None,
},
16,
)
.await;
let first = s.next().await.expect("first frame").expect("initial backing");
b.set_sampled_limit(0, Duration::ZERO);
for _ in 0..3 {
drop(s.next().await.expect("tail frame").expect("reuse charged storage"));
}
let error = s
.next()
.await
.expect("new backing denied")
.expect_err("live frame prevents reclaim");
assert!(crate::disk::background_memory::is_memory_pressure(&error));
assert!(s.next().await.is_none());
assert_eq!(b.snapshot().used, 16);
drop(first);
assert_eq!(b.snapshot().used, 0);
}
#[tokio::test]
async fn eof_and_error_release_spare_but_keep_live_output_charged() {
for fail_at in [None, Some(4)] {
let b = budget(16);
let mut s = stream(
&b,
ShortReader {
offset: 0,
length: 4,
chunk: 4,
fail_at,
},
16,
)
.await;
let frame = s.next().await.expect("first short frame").expect("payload");
match fail_at {
None => assert!(s.next().await.is_none()),
Some(_) => assert_eq!(
s.next()
.await
.expect("read error")
.expect_err("preserve source failure")
.kind(),
io::ErrorKind::PermissionDenied
),
}
assert!(s.next().await.is_none());
assert_eq!(b.snapshot().used, 16);
assert_eq!(frame.as_ref(), &DATA[..4]);
drop(frame);
assert_eq!(b.snapshot().used, 0);
}
}
#[test]
fn runtime_shutdown_drops_a_pending_stream_without_refunding_early() {
for abort in [false, true] {
let b = budget(16);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let (_tx, reader) = tokio::io::duplex(4);
let (began, wait) = tokio::sync::oneshot::channel();
let memory = b.clone();
let handle = rt.spawn(async move {
let mut s = stream(&memory, reader, 16).await;
assert!(futures::poll!(s.next()).is_pending());
assert_eq!(memory.snapshot().used, 16);
began.send(()).expect("notify pending allocation");
let _ = s.next().await;
});
rt.block_on(wait).expect("stream pending");
assert_eq!(b.snapshot().used, 16);
if abort {
handle.abort();
let _ = rt.block_on(handle);
}
drop(rt);
assert_eq!(b.snapshot().used, 0);
}
}
}
+13 -4
View File
@@ -17,10 +17,10 @@ use crate::cluster::rpc::http_auth::RPC_REPLAY_SCOPE_VERSION_HEADER;
use crate::cluster::rpc::http_auth::{
AuthenticatedPeerReplayCapabilities, RPC_AUTH_VERSION_HEADER, RPC_AUTH_VERSION_V2, RPC_BOOT_EPOCH_CHALLENGE_HEADER,
RPC_CONTENT_SHA256_HEADER, RPC_REPLAY_CACHE_CAPABILITY_HEADER, RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER,
RollingMutationBodyDigest, TIMESTAMP_HEADER, internode_rpc_body_digest_strict,
verify_tonic_peer_replay_capabilities_response,
RollingMutationBodyDigest, TIMESTAMP_HEADER, gen_tonic_replay_scope_headers_with_admission, internode_rpc_body_digest_strict,
rpc_background_admission, verify_tonic_peer_replay_capabilities_response,
};
use crate::cluster::rpc::{gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience};
use crate::cluster::rpc::{gen_tonic_signature_headers, normalize_tonic_rpc_audience};
#[cfg(test)]
use crate::cluster::rpc::{tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers};
use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
@@ -413,7 +413,16 @@ where
.get(RPC_CONTENT_SHA256_HEADER)
.and_then(|value| value.to_str().ok()),
) {
match gen_tonic_replay_scope_headers(audience, request.uri().path(), timestamp, content_sha256, boot_epoch) {
match rpc_background_admission(request.headers()).and_then(|background| {
gen_tonic_replay_scope_headers_with_admission(
audience,
request.uri().path(),
timestamp,
content_sha256,
boot_epoch,
background,
)
}) {
Ok(headers) => request.headers_mut().extend(headers),
Err(error) => debug!(error = %error, "could not attach replay-scoped RPC signature"),
}
+195 -24
View File
@@ -61,6 +61,9 @@ use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
const SIGNATURE_HEADER: &str = "x-rustfs-signature";
pub const RPC_BACKGROUND_ADMISSION_HEADER: &str = "x-rustfs-rpc-background-admission";
const RPC_BACKGROUND_ADMISSION_V1: &str = "v1";
const RPC_BACKGROUND_ADMISSION_DOMAIN: &[u8] = b"rustfs-rpc-background-admission-v1\0";
pub(crate) const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp";
pub(crate) const RPC_AUTH_VERSION_HEADER: &str = "x-rustfs-rpc-auth-version";
const RPC_SIGNATURE_V2_HEADER: &str = "x-rustfs-rpc-signature-v2";
@@ -77,6 +80,46 @@ pub(crate) const RPC_REPLAY_CACHE_CAPABILITY_HEADER: &str = "x-rustfs-rpc-replay
pub(crate) const RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER: &str = "x-rustfs-rpc-replay-cache-capability-proof";
const RPC_REPLAY_SCOPE_VERSION_V3: &str = "3";
const RPC_RESPONSE_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-response-proof-v1\0";
tokio::task_local! {
static RPC_BACKGROUND_REQUEST: bool;
}
pub(crate) fn current_rpc_background_request() -> bool {
RPC_BACKGROUND_REQUEST
.try_with(|background| *background)
.unwrap_or_else(|_| crate::disk::has_resource_io_scope())
}
/// Preserve the authenticated wire class while polling locally admitted work.
/// This scope carries only the class, never node, disk, or memory ownership.
pub async fn with_rpc_background_request<F: std::future::Future>(background: bool, operation: F) -> F::Output {
RPC_BACKGROUND_REQUEST.scope(background, operation).await
}
/// Parse the request class only; callers must verify the signature before using it.
pub fn rpc_background_admission(headers: &HeaderMap) -> std::io::Result<bool> {
let mut values = headers.get_all(RPC_BACKGROUND_ADMISSION_HEADER).iter();
match (values.next(), values.next()) {
(None, None) => Ok(false),
(Some(value), None) if value == RPC_BACKGROUND_ADMISSION_V1 => Ok(true),
_ => Err(std::io::Error::other("Invalid RPC background admission version")),
}
}
fn bind_background_admission(mac: &mut HmacSha256, background: bool) {
if background {
// Every auth lane binds the class, so stripping the marker or falling
// back to an older signature cannot turn admitted work into foreground I/O.
mac.update(RPC_BACKGROUND_ADMISSION_DOMAIN);
}
}
fn insert_background_admission(headers: &mut HeaderMap, background: bool) {
if background {
headers.insert(RPC_BACKGROUND_ADMISSION_HEADER, HeaderValue::from_static(RPC_BACKGROUND_ADMISSION_V1));
}
}
const RPC_REPLAY_SCOPE_DOMAIN: &[u8] = b"rustfs-rpc-replay-scope-v3\0";
const RPC_BOOT_EPOCH_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-boot-epoch-proof-v1\0";
const RPC_REPLAY_CACHE_CAPABILITY_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-replay-cache-capability-proof-v1\0";
@@ -519,21 +562,23 @@ fn redacted_rpc_path(url: &str) -> String {
}
/// Generate HMAC-SHA256 signature for the given data
fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64) -> String {
fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64, background: bool) -> String {
let data = signature_payload(url, method, timestamp);
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
bind_background_admission(&mut mac, background);
mac.update(data.as_bytes());
let result = mac.finalize();
base64_simd::STANDARD.encode_to_string(result.into_bytes())
}
fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool {
fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str, background: bool) -> bool {
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
return false;
};
let data = signature_payload(url, method, timestamp);
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
bind_background_admission(&mut mac, background);
mac.update(data.as_bytes());
mac.verify_slice(&signature).is_ok()
}
@@ -771,6 +816,7 @@ pub fn verify_ns_scanner_capability_with_checkpoint_coverage(
#[derive(Clone, Copy)]
struct SignatureV2Scope<'a> {
background: bool,
audience: &'a str,
service: &'a str,
rpc_method: &'a str,
@@ -780,6 +826,7 @@ struct SignatureV2Scope<'a> {
}
fn update_signature_v2(mac: &mut HmacSha256, scope: SignatureV2Scope<'_>) {
bind_background_admission(mac, scope.background);
for part in [
b"rustfs-rpc-auth-v2|".as_slice(),
scope.audience.as_bytes(),
@@ -818,6 +865,7 @@ fn verify_signature_v2(secret: &str, scope: SignatureV2Scope<'_>, signature: &st
#[derive(Clone, Copy)]
struct ReplayScope<'a> {
background: bool,
audience: &'a str,
path: &'a str,
timestamp: &'a str,
@@ -827,6 +875,7 @@ struct ReplayScope<'a> {
}
fn update_replay_scope(mac: &mut HmacSha256, scope: ReplayScope<'_>) {
bind_background_admission(mac, scope.background);
mac.update(RPC_REPLAY_SCOPE_DOMAIN);
for part in [
scope.audience.as_bytes(),
@@ -970,6 +1019,24 @@ pub fn gen_tonic_replay_scope_headers(
timestamp: &str,
content_sha256: &str,
boot_epoch: Uuid,
) -> std::io::Result<HeaderMap> {
gen_tonic_replay_scope_headers_with_admission(
audience,
path,
timestamp,
content_sha256,
boot_epoch,
current_rpc_background_request(),
)
}
pub(crate) fn gen_tonic_replay_scope_headers_with_admission(
audience: &str,
path: &str,
timestamp: &str,
content_sha256: &str,
boot_epoch: Uuid,
background: bool,
) -> std::io::Result<HeaderMap> {
if audience.is_empty() || !path.starts_with('/') || !valid_content_sha256(content_sha256) || boot_epoch.is_nil() {
return Err(std::io::Error::other("Invalid replay-scoped RPC signing scope"));
@@ -983,6 +1050,7 @@ pub fn gen_tonic_replay_scope_headers(
let signature = generate_replay_scope_signature(
&get_shared_secret()?,
ReplayScope {
background,
audience,
path,
timestamp,
@@ -992,6 +1060,7 @@ pub fn gen_tonic_replay_scope_headers(
},
)?;
let mut headers = HeaderMap::new();
insert_background_admission(&mut headers, background);
headers.insert(RPC_REPLAY_SCOPE_VERSION_HEADER, HeaderValue::from_static(RPC_REPLAY_SCOPE_VERSION_V3));
headers.insert(
RPC_REPLAY_SCOPE_SIGNATURE_HEADER,
@@ -1226,10 +1295,12 @@ pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) -
pub fn gen_signature_headers(url: &str, method: &Method) -> std::io::Result<HeaderMap> {
let secret = get_shared_secret()?;
let timestamp = OffsetDateTime::now_utc().unix_timestamp();
let background = current_rpc_background_request();
let signature = generate_signature(&secret, url, method, timestamp);
let signature = generate_signature(&secret, url, method, timestamp, background);
let mut headers = HeaderMap::new();
insert_background_admission(&mut headers, background);
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).expect("Invalid header value"));
headers.insert(
TIMESTAMP_HEADER,
@@ -1264,10 +1335,12 @@ pub fn gen_tonic_signature_headers(
let timestamp_header = timestamp.to_string();
let body_nonce = (content_sha256 != UNSIGNED_PAYLOAD).then(|| Uuid::new_v4().to_string());
let nonce = body_nonce.as_deref().unwrap_or(UNSIGNED_PAYLOAD_NONCE);
let legacy_signature = generate_signature(&secret, TONIC_RPC_PREFIX, &Method::GET, timestamp);
let background = current_rpc_background_request();
let legacy_signature = generate_signature(&secret, TONIC_RPC_PREFIX, &Method::GET, timestamp, background);
let signature_v2 = generate_signature_v2(
&secret,
SignatureV2Scope {
background,
audience,
service,
rpc_method,
@@ -1278,6 +1351,7 @@ pub fn gen_tonic_signature_headers(
)?;
let mut headers = HeaderMap::new();
insert_background_admission(&mut headers, background);
headers.insert(SIGNATURE_HEADER, header_value(&legacy_signature, SIGNATURE_HEADER)?);
headers.insert(TIMESTAMP_HEADER, header_value(&timestamp_header, TIMESTAMP_HEADER)?);
headers.insert(RPC_AUTH_VERSION_HEADER, HeaderValue::from_static(RPC_AUTH_VERSION_V2));
@@ -1501,6 +1575,7 @@ fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &Hea
if !verify_replay_scope_signature(
&secret,
ReplayScope {
background: rpc_background_admission(headers)?,
audience,
path,
timestamp,
@@ -1625,6 +1700,9 @@ fn verify_tonic_rpc_signature_with_strictness(
strict: bool,
) -> std::io::Result<()> {
if !has_v2_auth_headers(headers) {
if rpc_background_admission(headers)? {
return Err(std::io::Error::other("RPC background admission requires v2 authentication"));
}
// RUSTFS_COMPAT_TODO(heal-rpc-auth-v2): accept old peers during rolling upgrades. Remove after the minimum
// supported RustFS peer version sends v2 authentication on every internode gRPC request.
if strict {
@@ -1696,6 +1774,7 @@ fn verify_tonic_rpc_signature_with_strictness(
if !verify_signature_v2(
&secret,
SignatureV2Scope {
background: rpc_background_admission(headers)?,
audience,
service,
rpc_method,
@@ -1736,7 +1815,7 @@ pub fn verify_rpc_signature(url: &str, method: &Method, headers: &HeaderMap) ->
// Verify signature with constant-time HMAC comparison.
let secret = get_shared_secret()?;
if !verify_signature(&secret, url, method, timestamp, signature) {
if !verify_signature(&secret, url, method, timestamp, signature, rpc_background_admission(headers)?) {
let rpc_path = redacted_rpc_path(url);
error!(
rpc_path = %rpc_path,
@@ -1828,6 +1907,98 @@ mod tests {
runtime_sources::ensure_test_rpc_secret();
}
#[tokio::test]
async fn background_admission_is_bound_in_http_and_both_tonic_signature_lanes() {
ensure_test_rpc_secret();
let secret = get_shared_secret().expect("RPC secret");
let path = "/node_service.NodeService/ReadAll";
for background in [false, true] {
let (http, tonic) = with_rpc_background_request(background, async {
(
gen_signature_headers("/rustfs/rpc/read?disk=a", &Method::GET).expect("HTTP signature"),
gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "ReadAll", None)
.expect("gRPC signature"),
)
})
.await;
assert_eq!(rpc_background_admission(&http).expect("HTTP class"), background);
assert_eq!(rpc_background_admission(&tonic).expect("gRPC class"), background);
verify_rpc_signature("/rustfs/rpc/read?disk=a", &Method::GET, &http).expect("HTTP authentication");
verify_tonic_rpc_signature_with_strictness("node-a:9000", path, &tonic, true).expect("v2 authentication");
let timestamp = tonic[TIMESTAMP_HEADER].to_str().expect("timestamp");
let content = tonic[RPC_CONTENT_SHA256_HEADER].to_str().expect("content digest");
assert_eq!(
verify_signature_v2(
&secret,
SignatureV2Scope {
background: false,
audience: "node-a:9000",
service: "node_service.NodeService",
rpc_method: "ReadAll",
timestamp,
nonce: tonic[RPC_NONCE_HEADER].to_str().expect("nonce"),
content_sha256: content,
},
tonic[RPC_SIGNATURE_V2_HEADER].to_str().expect("signature"),
),
!background,
"an old peer cannot authenticate a background request as foreground"
);
let mut replay = tonic.clone();
// The channel can run outside the caller's task-local scope.
replay.extend(
gen_tonic_replay_scope_headers_with_admission(
"node-a:9000",
path,
timestamp,
content,
tonic_rpc_boot_epoch(),
background,
)
.expect("v3 signature"),
);
for mutation in ["strip-or-add", "unknown", "duplicate"] {
let alter = |headers: &HeaderMap| {
let mut changed = headers.clone();
match mutation {
"strip-or-add" if background => {
changed.remove(RPC_BACKGROUND_ADMISSION_HEADER);
}
"strip-or-add" => insert_background_admission(&mut changed, true),
"unknown" => {
changed.insert(RPC_BACKGROUND_ADMISSION_HEADER, HeaderValue::from_static("v2"));
}
_ => {
insert_background_admission(&mut changed, true);
changed.append(RPC_BACKGROUND_ADMISSION_HEADER, HeaderValue::from_static("v1"));
}
}
changed
};
assert!(verify_rpc_signature("/rustfs/rpc/read?disk=a", &Method::GET, &alter(&http)).is_err());
assert!(verify_tonic_rpc_signature_with_strictness("node-a:9000", path, &alter(&tonic), true).is_err());
assert!(verify_tonic_replay_scope_signature("node-a:9000", path, &alter(&replay)).is_err());
}
verify_tonic_replay_scope_signature("node-a:9000", path, &replay).expect("v3 authentication");
assert!(
verify_tonic_replay_scope_signature("node-a:9000", path, &replay).is_err(),
"replay rejected"
);
if background {
let mut downgraded = HeaderMap::new();
for name in [SIGNATURE_HEADER, TIMESTAMP_HEADER, RPC_BACKGROUND_ADMISSION_HEADER] {
downgraded.insert(name, tonic[name].clone());
}
assert!(verify_tonic_rpc_signature_with_strictness("node-a:9000", path, &downgraded, false).is_err());
downgraded.remove(RPC_BACKGROUND_ADMISSION_HEADER);
assert!(verify_tonic_rpc_signature_with_strictness("node-a:9000", path, &downgraded, false).is_err());
}
}
}
#[test]
fn namespace_scanner_capability_proof_binds_challenge_and_server_epoch() {
let secret = "test-scanner-capability-secret";
@@ -1965,8 +2136,8 @@ mod tests {
let method = Method::GET;
let timestamp = 1640995200; // Fixed timestamp
let signature1 = generate_signature(secret, url, &method, timestamp);
let signature2 = generate_signature(secret, url, &method, timestamp);
let signature1 = generate_signature(secret, url, &method, timestamp, false);
let signature2 = generate_signature(secret, url, &method, timestamp, false);
assert_eq!(signature1, signature2, "Same inputs should produce same signature");
assert!(!signature1.is_empty(), "Signature should not be empty");
@@ -1979,10 +2150,10 @@ mod tests {
let method = Method::GET;
let timestamp = 1640995200;
let signature1 = generate_signature(secret, url, &method, timestamp);
let signature2 = generate_signature(secret, "http://different.com/api/test2", &method, timestamp);
let signature3 = generate_signature(secret, url, &Method::POST, timestamp);
let signature4 = generate_signature(secret, url, &method, timestamp + 1);
let signature1 = generate_signature(secret, url, &method, timestamp, false);
let signature2 = generate_signature(secret, "http://different.com/api/test2", &method, timestamp, false);
let signature3 = generate_signature(secret, url, &Method::POST, timestamp, false);
let signature4 = generate_signature(secret, url, &method, timestamp + 1, false);
assert_ne!(signature1, signature2, "Different URLs should produce different signatures");
assert_ne!(signature1, signature3, "Different methods should produce different signatures");
@@ -2088,14 +2259,14 @@ mod tests {
let url = "http://example.com/api/test";
let method = Method::GET;
let timestamp = 1640995200;
let signature = generate_signature(secret, url, &method, timestamp);
let signature = generate_signature(secret, url, &method, timestamp, false);
let mut tampered = base64_simd::STANDARD.decode_to_vec(&signature).unwrap();
tampered[0] ^= 1;
let tampered_signature = base64_simd::STANDARD.encode_to_string(tampered);
assert!(verify_signature(secret, url, &method, timestamp, &signature));
assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature));
assert!(!verify_signature(secret, url, &method, timestamp, "invalid-signature"));
assert!(verify_signature(secret, url, &method, timestamp, &signature, false));
assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature, false));
assert!(!verify_signature(secret, url, &method, timestamp, "invalid-signature", false));
}
#[test]
@@ -2112,11 +2283,11 @@ mod tests {
);
let method = Method::GET;
let timestamp = 1_640_995_200;
let signature = generate_signature(secret, signed_url, &method, timestamp);
let signature = generate_signature(secret, signed_url, &method, timestamp, false);
assert!(verify_signature(secret, signed_url, &method, timestamp, &signature));
assert!(!verify_signature(secret, downgraded_url, &method, timestamp, &signature));
assert!(!verify_signature(secret, tampered_body_digest, &method, timestamp, &signature));
assert!(verify_signature(secret, signed_url, &method, timestamp, &signature, false));
assert!(!verify_signature(secret, downgraded_url, &method, timestamp, &signature, false));
assert!(!verify_signature(secret, tampered_body_digest, &method, timestamp, &signature, false));
}
#[test]
@@ -2126,7 +2297,7 @@ mod tests {
let method = Method::GET;
let timestamp = OffsetDateTime::now_utc().unix_timestamp();
let secret = get_shared_secret().expect("test RPC secret should resolve");
let expected_signature = generate_signature(&secret, url, &method, timestamp);
let expected_signature = generate_signature(&secret, url, &method, timestamp, false);
let invalid_signature = "invalid-signature";
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::fmt()
@@ -2164,7 +2335,7 @@ mod tests {
// Set expired timestamp (older than SIGNATURE_VALID_DURATION)
let expired_timestamp = OffsetDateTime::now_utc().unix_timestamp() - SIGNATURE_VALID_DURATION - 10;
let secret = get_shared_secret().expect("test RPC secret should resolve");
let signature = generate_signature(&secret, url, &method, expired_timestamp);
let signature = generate_signature(&secret, url, &method, expired_timestamp, false);
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap());
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&expired_timestamp.to_string()).unwrap());
@@ -2186,7 +2357,7 @@ mod tests {
let future_timestamp = OffsetDateTime::now_utc().unix_timestamp() + SIGNATURE_VALID_DURATION + 10;
let secret = get_shared_secret().expect("test RPC secret should resolve");
let signature = generate_signature(&secret, url, &method, future_timestamp);
let signature = generate_signature(&secret, url, &method, future_timestamp, false);
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap());
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&future_timestamp.to_string()).unwrap());
@@ -2300,7 +2471,7 @@ mod tests {
// Test timestamp just within valid duration
let valid_timestamp = current_time - SIGNATURE_VALID_DURATION + 1;
let signature = generate_signature(&secret, url, &method, valid_timestamp);
let signature = generate_signature(&secret, url, &method, valid_timestamp, false);
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap());
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&valid_timestamp.to_string()).unwrap());
@@ -2311,7 +2482,7 @@ mod tests {
// Test timestamp just outside valid duration
let mut headers = HeaderMap::new();
let invalid_timestamp = current_time - SIGNATURE_VALID_DURATION - 15;
let signature = generate_signature(&secret, url, &method, invalid_timestamp);
let signature = generate_signature(&secret, url, &method, invalid_timestamp, false);
headers.insert(SIGNATURE_HEADER, HeaderValue::from_str(&signature).unwrap());
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&invalid_timestamp.to_string()).unwrap());
@@ -33,7 +33,9 @@ use rustfs_config::{
DEFAULT_INTERNODE_DATA_TRANSPORT, ENV_RUSTFS_INTERNODE_DATA_TRANSPORT, INTERNODE_DATA_TRANSPORT_TCP,
KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS,
};
use rustfs_rio::{ChunkReaderBox, HttpChunkReader, HttpReader, HttpWriter};
use rustfs_rio::{
ChunkReaderBox, HttpChunkReader, HttpReader, HttpWriter, HttpWriterMemoryReservation, HttpWriterMemoryReserver,
};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::future::Future;
@@ -66,6 +68,19 @@ const PUT_FILE_CAPABILITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const CONTENT_TYPE_JSON: &str = "application/json";
const CONTENT_TYPE_MSGPACK: &str = "application/msgpack";
fn http_writer_memory_reserver() -> Option<HttpWriterMemoryReserver> {
crate::disk::current_resource_unit()
.and_then(|resources| resources.memory_budget())
.map(|budget| {
// Payloads can outlive the operation; never retain its node or disk units.
Arc::new(move |capacity| {
budget
.reserve(capacity)
.map(|reservation| Box::new(reservation) as HttpWriterMemoryReservation)
}) as HttpWriterMemoryReserver
})
}
fn unsupported_transport_message(transport: &str) -> String {
format!(
"invalid {ENV_RUSTFS_INTERNODE_DATA_TRANSPORT}={transport:?}; supported values: {}",
@@ -346,13 +361,17 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
}
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
let memory_reserver = http_writer_memory_reserver();
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
let auth_scope = server_epoch.map(|server_epoch| (Uuid::new_v4(), server_epoch));
let url = build_put_file_stream_url(&request, auth_scope);
let endpoint = request.endpoint;
let mut headers = json_headers();
build_auth_headers(&url, &Method::PUT, &mut headers)?;
let writer = HttpWriter::new(url.clone(), Method::PUT, headers).await?;
let writer = match memory_reserver {
Some(reserve) => HttpWriter::new_with_memory_reserver(url.clone(), Method::PUT, headers, reserve).await?,
None => HttpWriter::new(url.clone(), Method::PUT, headers).await?,
};
match auth_scope {
Some((nonce, server_epoch)) => Ok(Box::new(PutFileAuthWriter::new(writer, url, nonce, endpoint, server_epoch))),
None => Ok(Box::new(writer)),
@@ -948,6 +967,193 @@ mod tests {
use tokio::io::AsyncWriteExt;
use tokio::sync::{Barrier, Notify};
fn memory_test_write_request(authenticated: bool) -> WriteStreamRequest {
crate::runtime::sources::ensure_test_rpc_secret();
let endpoint = format!("http://writer-memory-{}.invalid", Uuid::new_v4());
put_file_capability_cache_entry(&endpoint).write().cached = Some(if authenticated {
PutFileCapabilityState::V1 {
server_epoch: Uuid::new_v4(),
revalidate_after: Instant::now() + PUT_FILE_V1_CAPABILITY_TTL,
}
} else {
PutFileCapabilityState::LegacyUntil(Instant::now() + PUT_FILE_LEGACY_CAPABILITY_TTL)
});
WriteStreamRequest {
endpoint,
disk: "disk-a".to_owned(),
volume: "bucket".to_owned(),
path: "part.1".to_owned(),
append: false,
size: 4,
}
}
#[tokio::test]
async fn http_write_memory_reserver_outlives_only_the_memory_scope() {
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
assert!(http_writer_memory_reserver().is_none(), "foreground must use the existing writer");
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(2 * 1024 * 1024, Duration::from_secs(30));
let (resources, gates) = test_resource_units(2);
let request = memory_test_write_request(true);
let endpoint = request.endpoint.clone();
let writer = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
TcpHttpInternodeDataTransport.open_write(request),
)
.await;
PUT_FILE_CAPABILITY_CACHE.write().remove(&endpoint);
let mut writer = writer.expect("admitted transport writer");
assert!(
gates.iter().all(|gate| gate.snapshot().active == 0),
"the callback must not retain disk units"
);
assert_eq!(memory.snapshot().used, 1024 * 1024);
writer.write_all(b"data").await.expect("buffered bytes");
writer.flush().await.expect("transfer charged allocation to queue");
memory.set_sampled_limit(2 * 1024 * 1024, Duration::ZERO);
writer
.write_all(&vec![0x5a; 1024 * 1024 - 4])
.await
.expect("reuse the already charged tail after observation expiry");
assert_eq!(memory.snapshot().used, 1024 * 1024, "reuse must not allocate separate storage");
let error = writer
.write(b"rejected")
.await
.expect_err("expired observation must reject new storage");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert!(
crate::disk::background_memory::is_memory_pressure(&error),
"preserve the typed memory-pressure marker"
);
assert_eq!(memory.snapshot().used, 1024 * 1024, "queued storage remains charged after expiry");
drop(writer);
tokio::task::yield_now().await;
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn http_write_memory_denial_preserves_the_budget_error() {
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
for (authenticated, sample) in [false, true].into_iter().flat_map(|authenticated| {
[
None,
Some((1024 * 1024 - 1, Duration::from_secs(30))),
Some((1024 * 1024, Duration::ZERO)),
]
.into_iter()
.map(move |sample| (authenticated, sample))
}) {
let memory = BackgroundMemoryBudget::new();
if let Some((limit, valid_for)) = sample {
memory.set_sampled_limit(limit, valid_for);
}
let (resources, _) = test_resource_units(1);
let request = memory_test_write_request(authenticated);
let endpoint = request.endpoint.clone();
let result = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
TcpHttpInternodeDataTransport.open_write(request),
)
.await;
PUT_FILE_CAPABILITY_CACHE.write().remove(&endpoint);
let error = match result {
Err(Error::Io(error)) => error,
Err(error) => panic!("unexpected transport error: {error}"),
Ok(_) => panic!("unavailable memory must reject construction"),
};
assert!(crate::disk::background_memory::is_memory_pressure(&error));
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn admitted_remote_reads_bind_receiver_admission_to_the_http_signature() {
use crate::disk::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
crate::runtime::sources::ensure_test_rpc_secret();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("remote admission listener");
let address = listener.local_addr().expect("remote admission address");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("remote read connection");
let mut bytes = Vec::new();
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let mut chunk = [0; 1024];
let read = socket.read(&mut chunk).await.expect("remote read headers");
assert!(read > 0 && bytes.len() < 16 * 1024, "bounded complete request headers");
bytes.extend_from_slice(&chunk[..read]);
}
let request = std::str::from_utf8(&bytes).expect("HTTP request");
let mut lines = request.split("\r\n");
let target = lines
.next()
.expect("request line")
.split_whitespace()
.nth(1)
.expect("request target")
.to_owned();
let mut headers = HeaderMap::new();
for line in lines.take_while(|line| !line.is_empty()) {
let (name, value) = line.split_once(':').expect("header field");
headers.append(
http::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"),
HeaderValue::from_str(value.trim()).expect("header value"),
);
}
socket
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\nconnection: close\r\n\r\nhello")
.await
.expect("remote read response");
(target, headers)
});
let gate = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let resource = gate.try_acquire().expect("sender device admission");
let mut reader = tokio::time::timeout(
Duration::from_secs(5),
with_background_resource_unit(
resource,
TcpHttpInternodeDataTransport.open_read(ReadStreamRequest {
endpoint: format!("http://{address}"),
disk: "disk-a".to_owned(),
volume: "bucket".to_owned(),
path: "part.1".to_owned(),
offset: 0,
length: 5,
stall_timeout: Some(Duration::from_secs(5)),
}),
),
)
.await
.expect("bounded remote request")
.expect("remote reader");
let mut body = Vec::new();
reader.read_to_end(&mut body).await.expect("complete remote body");
assert_eq!(body, b"hello");
let (target, headers) = server.await.expect("remote request capture");
assert_eq!(gate.snapshot().active, 0, "remote body does not retain the sender's device");
assert_eq!(
headers
.get("x-rustfs-rpc-background-admission")
.and_then(|value| value.to_str().ok()),
Some("v1"),
"resource-controlled remote work must request receiver-side admission"
);
crate::cluster::rpc::verify_rpc_signature(&target, &Method::GET, &headers).expect("bound request signature");
let mut stripped = headers;
stripped.remove("x-rustfs-rpc-background-admission");
assert!(
crate::cluster::rpc::verify_rpc_signature(&target, &Method::GET, &stripped).is_err(),
"removing background admission must invalidate the signature"
);
}
async fn wait_for_capability_flight_waiters(entry: &PutFileCapabilityCacheEntry, waiters: usize) {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
+13 -10
View File
@@ -13,6 +13,8 @@
// limitations under the License.
pub(crate) mod background_monitor;
pub(crate) mod background_pipe;
pub(crate) mod background_reader_stream;
pub(crate) mod client;
pub(crate) mod context_propagation;
pub(crate) mod http_auth;
@@ -33,16 +35,17 @@ pub use client::{
// Re-exported through `api::rpc`; not every item is consumed inside this crate.
#[allow(unused_imports)]
pub use http_auth::{
TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, gen_signature_headers,
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
set_tonic_mutation_body_digest, set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest,
sign_ns_scanner_capability, sign_ns_scanner_capability_with_checkpoint_coverage,
sign_ns_scanner_capability_with_tier_registry_generation, sign_put_file_capability, sign_tonic_rpc_response_proof,
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_ns_scanner_capability,
verify_ns_scanner_capability_with_checkpoint_coverage, verify_ns_scanner_capability_with_tier_registry_generation,
verify_put_file_auth_trailer, verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response,
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned,
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
RPC_BACKGROUND_ADMISSION_HEADER, TONIC_RPC_PREFIX, build_auth_headers, build_put_file_auth_trailer,
check_and_record_signed_rpc_nonce, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
normalize_tonic_rpc_audience, rpc_background_admission, set_tonic_canonical_body_digest, set_tonic_mutation_body_digest,
set_tonic_rolling_canonical_body_digest, set_tonic_rolling_mutation_body_digest, sign_ns_scanner_capability,
sign_ns_scanner_capability_with_checkpoint_coverage, sign_ns_scanner_capability_with_tier_registry_generation,
sign_put_file_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
tonic_rpc_auth_failure_reason, verify_ns_scanner_capability, verify_ns_scanner_capability_with_checkpoint_coverage,
verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer, verify_put_file_capability,
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
#[cfg(test)]
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
@@ -631,17 +631,6 @@ fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> R
Ok((version, epoch))
}
fn decode_mrf_lifecycle_reader_capability(expected_member: &str, result: &[u8]) -> Result<(u8, Uuid)> {
let (&maximum_format_version, identity) = result
.split_first()
.ok_or_else(|| Error::other("peer returned a truncated MRF lifecycle reader capability"))?;
if maximum_format_version == 0 || expected_member.trim().is_empty() {
return Err(Error::other("peer returned an invalid MRF lifecycle reader capability"));
}
let epoch = decode_remote_version_state_capability(expected_member, identity)?;
Ok((maximum_format_version, epoch))
}
#[derive(Clone, Debug)]
pub struct PeerLiveEventsBatch {
pub events: Vec<u8>,
@@ -1975,16 +1964,6 @@ impl PeerRestClient {
Ok((self.topology_member.clone(), supported_version, epoch))
}
/// Reports one peer's reader support without authorizing a lifecycle writer or rollback.
pub async fn probe_mrf_lifecycle_reader(&self, topology_fingerprint: String) -> Result<(String, u8, Uuid)> {
let probe = rustfs_protos::mrf_lifecycle_reader_capability_probe(Uuid::new_v4().as_bytes());
let result = self
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
.await?;
let (maximum_format_version, epoch) = decode_mrf_lifecycle_reader_capability(&self.topology_member, &result)?;
Ok((self.topology_member.clone(), maximum_format_version, epoch))
}
pub async fn probe_ilm_recovery_export(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
let probe = rustfs_protos::ilm_recovery_export_capability_probe(Uuid::new_v4().as_bytes());
let result = self
@@ -4354,49 +4333,6 @@ mod tests {
}
}
#[test]
fn mrf_lifecycle_reader_capability_decoder_preserves_the_advertised_version() {
let epoch = Uuid::new_v4();
for version in [1, 2, u8::MAX] {
let result = rustfs_protos::encode_mrf_lifecycle_reader_capability(version, "node-a:9000", epoch.as_bytes())
.expect("capability frame");
assert_eq!(
decode_mrf_lifecycle_reader_capability("node-a:9000", &result).expect("reported capability"),
(version, epoch)
);
}
}
#[test]
fn mrf_lifecycle_reader_capability_decoder_rejects_unbound_or_malformed_identity() {
let epoch = Uuid::new_v4();
let valid =
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", epoch.as_bytes()).expect("capability frame");
let mut invalid = vec![Vec::new(), vec![2], valid[..valid.len() - 1].to_vec()];
let mut zero = valid.clone();
zero[0] = 0;
invalid.push(zero);
let mut trailing = valid.clone();
trailing.push(0);
invalid.push(trailing);
let mut length_overflow = valid.clone();
length_overflow[1..9].copy_from_slice(&u64::MAX.to_be_bytes());
invalid.push(length_overflow);
let mut invalid_utf8 = valid.clone();
invalid_utf8[9] = 0xff;
invalid.push(invalid_utf8);
invalid.push(
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", Uuid::nil().as_bytes())
.expect("nil epoch fixture"),
);
for result in invalid {
assert!(decode_mrf_lifecycle_reader_capability("node-a:9000", &result).is_err());
}
assert!(decode_mrf_lifecycle_reader_capability("node-b:9000", &valid).is_err());
let empty = rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "", epoch.as_bytes()).expect("empty member fixture");
assert!(decode_mrf_lifecycle_reader_capability("", &empty).is_err());
}
#[test]
fn remote_version_state_capability_decoder_fails_closed() {
let epoch = Uuid::new_v4();
+114 -522
View File
@@ -17,6 +17,7 @@ use crate::cluster::rpc::client::{
node_service_delete_recovery_client, node_service_time_out_client, node_service_time_out_client_for_class,
node_service_time_out_client_no_auth,
};
use crate::cluster::rpc::http_auth::{current_rpc_background_request, with_rpc_background_request};
use crate::cluster::rpc::internode_data_transport::{
InternodeDataTransport, NsScannerCapabilityRequest, NsScannerStreamRequest, ReadStreamRequest, WalkDirStreamRequest,
WriteStreamRequest,
@@ -533,6 +534,7 @@ struct RetryingRemoteReader {
reader: Option<FileReader>,
transport: Arc<dyn InternodeDataTransport>,
request: ReadStreamRequest,
background: bool,
emitted: usize,
retried: bool,
initial_read_timeout: Option<Duration>,
@@ -553,6 +555,7 @@ impl RetryingRemoteReader {
reader: Some(reader),
transport,
request,
background: current_rpc_background_request(),
emitted: 0,
retried: false,
initial_read_timeout,
@@ -570,9 +573,10 @@ impl RetryingRemoteReader {
let request = resumed_read_request(&self.request, self.emitted)?;
let recovery_timeout = self.recovery_timeout;
let transport = Arc::clone(&self.transport);
self.resume = Some(AbortOnDropTask::new(tokio::spawn(async move {
with_remote_read_recovery_timeout(recovery_timeout, transport.open_read_fresh(request)).await
})));
self.resume = Some(AbortOnDropTask::new(tokio::spawn(with_rpc_background_request(
self.background,
async move { with_remote_read_recovery_timeout(recovery_timeout, transport.open_read_fresh(request)).await },
))));
Ok(())
}
@@ -680,6 +684,7 @@ struct RetryingRemoteChunkReader {
reader: Option<rustfs_rio::ChunkReaderBox>,
transport: Arc<dyn InternodeDataTransport>,
request: ReadStreamRequest,
background: bool,
emitted: usize,
retried: bool,
initial_read_timeout: Option<Duration>,
@@ -700,6 +705,7 @@ impl RetryingRemoteChunkReader {
reader: Some(reader),
transport,
request,
background: current_rpc_background_request(),
emitted: 0,
retried: false,
initial_read_timeout,
@@ -717,9 +723,10 @@ impl RetryingRemoteChunkReader {
let request = resumed_read_request(&self.request, self.emitted)?;
let recovery_timeout = self.recovery_timeout;
let transport = Arc::clone(&self.transport);
self.resume = Some(AbortOnDropTask::new(tokio::spawn(async move {
with_remote_read_recovery_timeout(recovery_timeout, transport.open_read_chunks_fresh(request)).await
})));
self.resume = Some(AbortOnDropTask::new(tokio::spawn(with_rpc_background_request(
self.background,
async move { with_remote_read_recovery_timeout(recovery_timeout, transport.open_read_chunks_fresh(request)).await },
))));
Ok(())
}
@@ -2375,300 +2382,7 @@ fn validate_decoded_file_info(file_info: &FileInfo) -> Result<()> {
file_info.validate_for_metadata_read().map_err(Into::into)
}
fn validate_artifact_repair_seal_response(
request: &rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest,
response: &rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse,
) -> Result<()> {
let canonical = rustfs_protos::canonical_seal_artifact_attempt_repairs_response_body(request, response)
.map_err(|_| Error::other("artifact repair seal response is too large"))?;
crate::cluster::rpc::verify_tonic_rpc_response_proof(&canonical, &response.response_proof)
.map_err(|_| Error::other("artifact repair seal response proof is invalid"))?;
if !response.success {
return Err(response.error.clone().unwrap_or_default().into());
}
let expected_disk = Uuid::from_slice(&request.expected_disk_id).ok().filter(|id| !id.is_nil());
if response.error.is_some() || expected_disk.is_none() || Uuid::from_slice(&response.disk_id).ok() != expected_disk {
return Err(Error::other("artifact repair seal response disk identity mismatch"));
}
Ok(())
}
impl RemoteDisk {
pub(crate) async fn rename_artifact_repair_data(
&self,
expected_disk: Uuid,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
expected_incarnation: Uuid,
) -> Result<RenameDataResp> {
use rustfs_protos::proto_gen::node_service::ArtifactRepairRenameDataRequest;
self.execute_with_timeout_for_op(
"artifact_repair_rename_data",
|| async {
let body = ArtifactRepairRenameDataRequest {
rename: Some(RenameDataRequest {
disk: self.endpoint.to_string(),
src_volume: source.0.to_owned(),
src_path: source.1.to_owned(),
dst_volume: destination.0.to_owned(),
dst_path: destination.1.to_owned(),
file_info: compat_json(fi)?,
file_info_bin: encode_file_info_msgpack(fi)?.into(),
scanner_publication_lease_token: Bytes::new(),
bucket_incarnation_id: expected_incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_rename_data_request_body(&body)
.map_err(|_| Error::other("artifact repair rename request is too large"))?;
let mut request = Request::new(body);
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
// Unimplemented is terminal: never downgrade repair publication to PUT or legacy RPCs.
let response = self
.get_client()
.await?
.artifact_repair_rename_data(request)
.await?
.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
decode_msgpack_or_json::<RenameDataResp>(
&response.rename_data_resp_bin,
&response.rename_data_resp,
"RenameDataResp",
)
},
get_max_timeout_duration(),
)
.await
}
pub(crate) async fn write_artifact_repair_metadata(
&self,
expected_disk: Uuid,
target: (&str, &str),
fi: &FileInfo,
expected_incarnation: Uuid,
) -> Result<()> {
use rustfs_protos::proto_gen::node_service::ArtifactRepairWriteMetadataRequest;
self.execute_with_timeout_for_op(
"artifact_repair_write_metadata",
|| async {
let body = ArtifactRepairWriteMetadataRequest {
metadata: Some(WriteMetadataRequest {
disk: self.endpoint.to_string(),
volume: target.0.to_owned(),
path: target.1.to_owned(),
file_info: compat_json(fi)?,
file_info_bin: encode_file_info_msgpack(fi)?.into(),
bucket_incarnation_id: expected_incarnation.as_bytes().to_vec().into(),
}),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_artifact_repair_write_metadata_request_body(&body)
.map_err(|_| Error::other("artifact repair metadata request is too large"))?;
let mut request = Request::new(body);
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = self
.get_client()
.await?
.artifact_repair_write_metadata(request)
.await?
.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
Ok(())
},
get_max_timeout_duration(),
)
.await
}
/// One authenticated scan (or explicitly distinct read-only identity probe).
/// Partial observations are never retried after output has begun.
pub async fn inventory_artifact_attempt(
&self,
expected_disk: Uuid,
target: (&str, Uuid),
prefix: &str,
phase: rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase,
deadline: tokio::time::Instant,
output: tokio::sync::mpsc::Sender<crate::disk::ArtifactInventoryFrame>,
) -> Result<()> {
use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion};
use rustfs_protos::{
artifact_inventory as protocol,
proto_gen::node_service::{ArtifactInventoryRequest, artifact_inventory_frame::Payload},
};
let (bucket, incarnation) = target;
let duration = deadline
.checked_duration_since(tokio::time::Instant::now())
.ok_or(Error::Timeout)?;
let body = ArtifactInventoryRequest {
protocol_version: protocol::VERSION,
invocation_id: Uuid::new_v4().as_bytes().to_vec().into(),
disk: self.endpoint.to_string(),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
bucket: bucket.to_owned(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
attempt_prefix: prefix.to_owned(),
timeout_millis: u64::try_from(duration.as_millis().min(u128::from(protocol::MAX_TIMEOUT_MILLIS)))
.map_err(Error::other)?,
phase: phase.into(),
};
protocol::validate_request(&body).map_err(Error::other)?;
tokio::time::timeout_at(deadline, async {
let canonical = protocol::canonical_request(&body).map_err(Error::other)?;
let mut request = Request::new(body.clone());
request.set_timeout(duration);
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let mut stream = self
.get_client()
.await?
.max_decoding_message_size(protocol::MAX_FRAME_BYTES)
.inventory_artifact_attempt(request)
.await?
.into_inner();
let mut state = protocol::StreamState::new();
let mut terminal = None;
while let Some(frame) = stream.message().await? {
state
.accept(&body, &frame, |canonical, proof| {
crate::cluster::rpc::verify_tonic_rpc_response_proof(canonical, proof).is_ok()
})
.map_err(Error::other)?;
match frame.payload {
Some(Payload::Entries(entries)) => {
let versions = entries
.versions
.into_iter()
.map(|entry| {
Ok(ArtifactInventoryVersion {
key: entry.key,
version_id: Uuid::from_slice(&entry.version_id).map_err(Error::other)?,
delete_marker: entry.delete_marker,
})
})
.collect::<Result<Vec<_>>>()?;
output
.send(ArtifactInventoryFrame::Entries {
sequence: frame.sequence,
versions,
})
.await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
}
Some(Payload::Complete(complete)) => terminal = Some(complete.entries),
Some(Payload::Verified(_)) => {}
None => return Err(Error::other("artifact inventory payload is absent")),
}
}
// A valid Complete followed by a transport failure or extra frame is
// still incomplete. Publish the local terminal only after clean EOF.
state.finish().map_err(Error::other)?;
if let Some(entries) = terminal {
output
.send(ArtifactInventoryFrame::Complete {
disk_id: expected_disk,
incarnation,
attempt_prefix: prefix.to_owned(),
entries,
})
.await
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
}
Ok(())
})
.await
.map_err(|_| Error::Timeout)?
}
pub(crate) async fn seal_artifact_puts(
&self,
expected_disk: Uuid,
bucket: &str,
incarnation: Uuid,
attempt_prefix: &str,
) -> Result<()> {
use rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest;
self.execute_with_timeout_for_op(
"seal_artifact_puts",
|| async {
let body = SealArtifactAttemptPutsRequest {
disk: self.endpoint.to_string(),
bucket: bucket.to_owned(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
attempt_prefix: attempt_prefix.to_owned(),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_seal_artifact_attempt_puts_request_body(&body)
.map_err(|_| Error::other("artifact seal request is too large"))?;
let mut request = Request::new(body.clone());
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = self
.get_client()
.await?
.seal_artifact_attempt_puts(request)
.await?
.into_inner();
let canonical = rustfs_protos::canonical_seal_artifact_attempt_puts_response_body(&body, &response)
.map_err(|_| Error::other("artifact seal response is too large"))?;
crate::cluster::rpc::verify_tonic_rpc_response_proof(&canonical, &response.response_proof)
.map_err(|_| Error::other("artifact seal response proof is invalid"))?;
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
if response.error.is_some() || Uuid::from_slice(&response.disk_id).ok() != Some(expected_disk) {
return Err(Error::other("artifact seal response disk identity mismatch"));
}
Ok(())
},
get_max_timeout_duration(),
)
.await
}
pub(crate) async fn seal_artifact_repairs(
&self,
expected_disk: Uuid,
bucket: &str,
incarnation: Uuid,
attempt_prefix: &str,
) -> Result<()> {
use rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest;
if expected_disk.is_nil() || incarnation.is_nil() {
return Err(Error::other("artifact repair seal requires non-nil disk and incarnation identities"));
}
self.execute_with_timeout_for_op(
"seal_artifact_repairs",
|| async {
let body = SealArtifactAttemptPutsRequest {
disk: self.endpoint.to_string(),
bucket: bucket.to_owned(),
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
attempt_prefix: attempt_prefix.to_owned(),
expected_disk_id: expected_disk.as_bytes().to_vec().into(),
};
let canonical = rustfs_protos::canonical_seal_artifact_attempt_repairs_request_body(&body)
.map_err(|_| Error::other("artifact repair seal request is too large"))?;
let mut request = Request::new(body.clone());
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical).map_err(Error::other)?;
let response = self
.get_client()
.await?
.seal_artifact_attempt_repairs(request)
.await?
.into_inner();
validate_artifact_repair_seal_response(&body, &response)
},
get_max_timeout_duration(),
)
.await
}
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) async fn rename_data_borrowed(
&self,
@@ -2678,39 +2392,20 @@ impl RemoteDisk {
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
self.rename_data_borrowed_with_fence((src_volume, src_path), fi, (dst_volume, dst_path), None, None)
self.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
.await
}
#[tracing::instrument(level = "trace", skip_all)]
pub(crate) async fn rename_data_borrowed_with_fence(
&self,
source: (&str, &str),
src_volume: &str,
src_path: &str,
fi: &FileInfo,
target: (&str, &str),
dst_volume: &str,
dst_path: &str,
scanner_publication_lease_token: Option<Uuid>,
artifact_incarnation: Option<Uuid>,
) -> Result<RenameDataResp> {
let (src_volume, src_path) = source;
let (dst_volume, dst_path) = target;
if let Some(scope) = crate::bucket::artifact::artifact_repair_scope(dst_volume, dst_path)
&& let Some(identity) = scope.receiver_identity()
{
scope.check().map_err(Error::other)?;
if artifact_incarnation.is_some() || scanner_publication_lease_token.is_some() {
return Err(Error::other("artifact repair cannot use a publication lease"));
}
let expected = self
.get_disk_id()
.await?
.filter(|id| !id.is_nil())
.ok_or_else(|| Error::other("artifact repair disk identity is unavailable"))?;
let result = self
.rename_artifact_repair_data(expected, (src_volume, src_path), fi, (dst_volume, dst_path), identity.incarnation)
.await;
scope.check().map_err(Error::other)?;
return result;
}
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
@@ -2733,11 +2428,8 @@ impl RemoteDisk {
let file_info_bin = encode_file_info_msgpack(fi)?;
let mut client = self.get_client().await?;
let mut request = Request::new(RenameDataRequest {
bucket_incarnation_id: artifact_incarnation
.or_else(|| {
crate::store::bucket_heal_scope_for_object(dst_volume, dst_path).map(|scope| scope.incarnation)
})
.map(|incarnation| incarnation.as_bytes().to_vec().into())
bucket_incarnation_id: crate::store::bucket_heal_scope_for_object(dst_volume, dst_path)
.map(|scope| scope.incarnation.as_bytes().to_vec().into())
.unwrap_or_default(),
disk: self.endpoint.to_string(),
src_volume: src_volume.to_string(),
@@ -2750,11 +2442,7 @@ impl RemoteDisk {
.map(|token| token.as_bytes().to_vec().into())
.unwrap_or_default(),
});
let canonical_body = if artifact_incarnation.is_some() {
rustfs_protos::canonical_artifact_rename_data_request_body(request.get_ref())
} else {
rustfs_protos::canonical_rename_data_request_body(request.get_ref())
};
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
let quota_fenced = rustfs_utils::http::metadata_compat::contains_key_str(
&fi.metadata,
@@ -2777,9 +2465,7 @@ impl RemoteDisk {
let rpc_started = rustfs_io_metrics::put_stage_timer();
let response = rustfs_io_metrics::observe_put_stage_future(
async {
if artifact_incarnation.is_some() {
client.artifact_rename_data(request).await
} else if quota_fenced {
if quota_fenced {
// The method itself proves quota-token enforcement; an old peer must fail closed.
client.rename_data_with_quota_fence(request).await
} else if incarnation_bound {
@@ -3525,21 +3211,6 @@ impl DiskAPI for RemoteDisk {
#[tracing::instrument(level = "trace", skip_all)]
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
if let Some(scope) = crate::bucket::artifact::artifact_repair_scope(volume, path)
&& let Some(identity) = scope.receiver_identity()
{
scope.check().map_err(Error::other)?;
let expected = self
.get_disk_id()
.await?
.filter(|id| !id.is_nil())
.ok_or_else(|| Error::other("artifact repair disk identity is unavailable"))?;
let result = self
.write_artifact_repair_metadata(expected, (volume, path), &fi, identity.incarnation)
.await;
scope.check().map_err(Error::other)?;
return result;
}
trace!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
@@ -4889,83 +4560,6 @@ mod tests {
static INIT: Once = Once::new();
#[tokio::test]
async fn artifact_repair_old_peer_unimplemented_never_downgrades_mutations() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("loopback test peer");
let endpoint = Endpoint {
url: url::Url::parse(&format!("{}/data/rustfs0", peer.addr)).expect("test disk endpoint"),
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
let disk = RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
Arc::new(TcpHttpInternodeDataTransport),
)
.await
.expect("remote test disk");
let expected_disk = Uuid::new_v4();
let incarnation = Uuid::new_v4();
let info = FileInfo::default();
let rename = disk
.rename_artifact_repair_data(
expected_disk,
("tmp", "staged"),
&info,
("artifacts", "outputs/tenant/run/attempt/image"),
incarnation,
)
.await
.expect_err("old peer cannot publish repair data");
let metadata = disk
.write_artifact_repair_metadata(expected_disk, ("artifacts", "outputs/tenant/run/attempt/image"), &info, incarnation)
.await
.expect_err("old peer cannot publish repair metadata");
let seal = disk
.seal_artifact_repairs(expected_disk, "artifacts", incarnation, "outputs/tenant/run/attempt")
.await
.expect_err("old peer cannot confirm repair closure");
for error in [rename, metadata, seal] {
let DiskError::Io(error) = error else {
panic!("expected gRPC status error");
};
let status = error
.get_ref()
.and_then(|error| error.downcast_ref::<crate::disk::error::RpcStatusError>())
.expect("preserved gRPC status");
assert_eq!(status.status().code(), Code::Unimplemented);
}
let paths = peer.peer.rpc_paths.lock().expect("RPC inventory").clone();
for method in [
"ArtifactRepairRenameData",
"ArtifactRepairWriteMetadata",
"SealArtifactAttemptRepairs",
] {
assert_eq!(
paths.iter().filter(|path| path.ends_with(&format!("/{method}"))).count(),
1,
"each repair method is sent once"
);
}
assert!(
paths.iter().all(|path| matches!(
path.rsplit('/').next(),
Some("ArtifactRepairRenameData" | "ArtifactRepairWriteMetadata" | "SealArtifactAttemptRepairs" | "Ping")
)),
"repair cannot downgrade to a legacy mutation: {paths:?}"
);
disk.cancel_token.cancel();
peer.stop().await;
}
#[tokio::test]
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
@@ -5061,93 +4655,6 @@ mod tests {
peer.stop().await;
}
#[test]
fn artifact_repair_seal_response_requires_proof_full_request_and_physical_identity() {
use rustfs_protos::proto_gen::node_service::{
Error as WireError, SealArtifactAttemptPutsRequest, SealArtifactAttemptPutsResponse,
};
runtime_sources::ensure_test_rpc_secret();
let request = SealArtifactAttemptPutsRequest {
disk: "disk-a".into(),
bucket: "artifacts".into(),
bucket_incarnation_id: vec![1; 16].into(),
attempt_prefix: "outputs/tenant/run/attempt".into(),
expected_disk_id: vec![2; 16].into(),
};
let mut response = SealArtifactAttemptPutsResponse {
success: true,
disk_id: request.expected_disk_id.clone(),
..Default::default()
};
let sign = |request: &SealArtifactAttemptPutsRequest, response: &mut SealArtifactAttemptPutsResponse| {
let canonical = rustfs_protos::canonical_seal_artifact_attempt_repairs_response_body(request, response)
.expect("repair response canonical");
response.response_proof = crate::cluster::rpc::http_auth::sign_tonic_rpc_response_proof(&canonical)
.expect("repair response proof")
.into();
};
assert!(validate_artifact_repair_seal_response(&request, &response).is_err(), "proof is mandatory");
sign(&request, &mut response);
validate_artifact_repair_seal_response(&request, &response).expect("signed exact physical receiver");
for mutate in [
|r: &mut SealArtifactAttemptPutsRequest| r.disk = "other-disk".into(),
|r: &mut SealArtifactAttemptPutsRequest| r.bucket = "other-bucket".into(),
|r: &mut SealArtifactAttemptPutsRequest| r.bucket_incarnation_id = vec![3; 16].into(),
|r: &mut SealArtifactAttemptPutsRequest| r.attempt_prefix = "other-attempt".into(),
|r: &mut SealArtifactAttemptPutsRequest| r.expected_disk_id = vec![4; 16].into(),
] {
let mut changed = request.clone();
mutate(&mut changed);
assert!(
validate_artifact_repair_seal_response(&changed, &response).is_err(),
"another request cannot reuse the proof"
);
}
let legacy = rustfs_protos::canonical_seal_artifact_attempt_puts_response_body(&request, &response).expect("PUT domain");
let mut legacy_response = response.clone();
legacy_response.response_proof = crate::cluster::rpc::http_auth::sign_tonic_rpc_response_proof(&legacy)
.expect("PUT proof")
.into();
assert!(
validate_artifact_repair_seal_response(&request, &legacy_response).is_err(),
"PUT proof cannot acknowledge repair seal"
);
for disk in [vec![], vec![0; 16], vec![3; 16], vec![1; 15]] {
let mut changed = response.clone();
changed.disk_id = disk.into();
sign(&request, &mut changed);
assert!(
validate_artifact_repair_seal_response(&request, &changed).is_err(),
"signed wrong or absent physical identity is invalid"
);
}
for mutate in [
|r: &mut SealArtifactAttemptPutsResponse| r.success = false,
|r: &mut SealArtifactAttemptPutsResponse| r.error = Some(WireError::default()),
] {
let mut changed = response.clone();
mutate(&mut changed);
assert!(
validate_artifact_repair_seal_response(&request, &changed).is_err(),
"response fields are bound"
);
sign(&request, &mut changed);
assert!(
validate_artifact_repair_seal_response(&request, &changed).is_err(),
"signed failure or inconsistent success is invalid"
);
}
let mut nil_request = request;
nil_request.expected_disk_id = vec![0; 16].into();
let mut nil_response = response;
nil_response.disk_id = nil_request.expected_disk_id.clone();
sign(&nil_request, &mut nil_response);
assert!(
validate_artifact_repair_seal_response(&nil_request, &nil_response).is_err(),
"nil physical identity is never a receipt"
);
}
#[test]
fn recoverable_batch_delete_rejects_invalid_error_codes_and_length() {
let errors = decode_delete_recovery_batch_errors(vec![None, Some(DiskError::FileNotFound.to_u32())], 2)
@@ -5538,7 +5045,6 @@ mod tests {
format_data: Bytes,
read_all_data: Bytes,
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
rpc_paths: Arc<StdMutex<Vec<String>>>,
unsupported_methods: Arc<StdMutex<Vec<String>>>,
}
@@ -5558,7 +5064,6 @@ mod tests {
format_data,
read_all_data,
next_format_read_gate: Arc::default(),
rpc_paths: Arc::default(),
unsupported_methods: Arc::default(),
}
}
@@ -5614,11 +5119,6 @@ mod tests {
}
fn call(&mut self, request: http::Request<B>) -> Self::Future {
self.peer
.rpc_paths
.lock()
.expect("RPC path inventory")
.push(request.uri().path().to_owned());
match request.uri().path() {
"/node_service.NodeService/DiskInfo" => {
#[derive(Clone)]
@@ -7578,6 +7078,98 @@ mod tests {
.await;
}
#[tokio::test]
async fn remote_read_retries_preserve_signed_admission_after_sender_scope_ends() {
use crate::cluster::rpc::{rpc_background_admission, verify_rpc_signature};
use crate::disk::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
crate::runtime::sources::ensure_test_rpc_secret();
for background in [false, true] {
for chunked in [false, true] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("retry listener");
let address = listener.local_addr().expect("retry address");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("fresh connection");
let mut bytes = Vec::new();
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let mut chunk = [0; 1024];
let read = socket.read(&mut chunk).await.expect("retry request");
assert!(read > 0 && bytes.len() < 16 * 1024, "bounded complete headers");
bytes.extend_from_slice(&chunk[..read]);
}
let request = std::str::from_utf8(&bytes).expect("HTTP request");
let mut lines = request.split("\r\n");
let target = lines
.next()
.expect("request line")
.split_whitespace()
.nth(1)
.expect("target")
.to_owned();
let mut headers = http::HeaderMap::new();
for line in lines.take_while(|line| !line.is_empty()) {
let (name, value) = line.split_once(':').expect("header");
headers.append(
http::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"),
http::HeaderValue::from_str(value.trim()).expect("header value"),
);
}
socket
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 6\r\nconnection: close\r\n\r\n456789")
.await
.expect("retry response");
(target, headers)
});
let gate = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let request = ReadStreamRequest {
endpoint: format!("http://{address}"),
..resume_request(10)
};
let create = async {
let transport = Arc::new(TcpHttpInternodeDataTransport);
if chunked {
Box::new(RetryingRemoteChunkReader::new_with_timeouts(
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec())),
transport,
request,
None,
None,
)) as FileReader
} else {
Box::new(RetryingRemoteReader::new_with_timeouts(
resume_step_reader(ResumeReadStep::PartialThenReset(b"0123".to_vec())),
transport,
request,
None,
None,
)) as FileReader
}
};
let mut reader = if background {
with_background_resource_unit(gate.try_acquire().expect("sender admission"), create).await
} else {
create.await
};
assert_eq!(gate.snapshot().active, 0, "retry reader cannot retain sender disk capacity");
let mut output = Vec::new();
tokio::time::timeout(
Duration::from_secs(5),
with_rpc_background_request(!background, reader.read_to_end(&mut output)),
)
.await
.expect("bounded retry")
.expect("resumed body");
assert_eq!(output, b"0123456789", "chunked={chunked}, background={background}");
let (target, headers) = server.await.expect("captured retry");
assert!(target.contains("offset=11") && target.contains("length=6"), "{target}");
assert_eq!(rpc_background_admission(&headers).expect("admission marker"), background);
verify_rpc_signature(&target, &http::Method::GET, &headers).expect("signed retry class");
}
}
}
#[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())]));
+8 -452
View File
@@ -438,64 +438,6 @@ where
Ok(data)
}
pub(crate) async fn read_config_observation<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
let opts = ObjectOptions {
suppress_read_repair: true,
no_lock: false,
skip_verify_bitrot: false,
..Default::default()
};
crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
api, file, &opts, max_bytes,
))
.await
}
pub(crate) async fn read_config_observation_no_lock<S>(
api: Arc<S>,
file: &str,
max_bytes: usize,
guard: &crate::object_api::WriteCommitGuard,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
if !guard.matches(RUSTFS_META_BUCKET, file) {
return Err(Error::InvalidArgument(
RUSTFS_META_BUCKET.to_owned(),
file.to_owned(),
"locked observation requires a matching write owner".to_owned(),
));
}
let lock_lost_error = || Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
bucket: RUSTFS_META_BUCKET.to_owned(),
object: file.to_owned(),
required: 1,
achieved: 0,
};
if guard.is_lock_lost() {
return Err(lock_lost_error());
}
let opts = ObjectOptions {
suppress_read_repair: true,
no_lock: true,
skip_verify_bitrot: false,
..Default::default()
};
let result = crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
api, file, &opts, max_bytes,
))
.await;
if guard.is_lock_lost() {
return Err(lock_lost_error());
}
result
}
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
where
S: EcstoreObjectIO,
@@ -542,22 +484,10 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
where
S: EcstoreObjectIO,
{
let (data, _obj) = read_config_preserve_empty_with_metadata_opts(api, file, &ObjectOptions::default()).await?;
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
Ok(data)
}
/// Preserve an empty config payload, its metadata, and caller-supplied read options.
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
api: Arc<S>,
file: &str,
opts: &ObjectOptions,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: EcstoreObjectIO,
{
read_config_with_metadata_inner(api, file, opts, true, None).await
}
pub async fn read_config_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
where
S: ObjectIO<
@@ -586,13 +516,15 @@ pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc
where
S: EcstoreObjectIO,
{
read_config_preserve_empty_with_metadata_opts(
read_config_with_metadata_inner(
api,
file,
&ObjectOptions {
no_lock: true,
..Default::default()
},
true,
None,
)
.await
}
@@ -2987,9 +2919,9 @@ mod tests {
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
read_config_preserve_empty_with_metadata_opts, read_config_with_metadata, read_config_without_migrate,
read_server_config_snapshot, save_config_with_opts_inner, save_server_config, save_server_config_snapshot,
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_config_with_opts_inner,
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_transaction_lock_path, storage_class_kvs_mut,
};
use crate::config::{audit, heal, notify, oidc, scanner};
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
@@ -5464,7 +5396,6 @@ mod tests {
heal_replacement: Option<Vec<u8>>,
heal_calls: AtomicUsize,
write_calls: AtomicUsize,
last_get_no_lock: AtomicBool,
last_put_no_lock: AtomicBool,
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
revision: AtomicUsize,
@@ -5480,7 +5411,6 @@ mod tests {
heal_replacement,
heal_calls: AtomicUsize::new(0),
write_calls: AtomicUsize::new(0),
last_get_no_lock: AtomicBool::new(false),
last_put_no_lock: AtomicBool::new(false),
last_put_preconditions: Mutex::new(None),
revision: AtomicUsize::new(1),
@@ -5536,9 +5466,8 @@ mod tests {
_object: &str,
_range: Option<HTTPRangeSpec>,
_h: HeaderMap,
opts: &ObjectOptions,
_opts: &ObjectOptions,
) -> Result<GetObjectReader> {
self.last_get_no_lock.store(opts.no_lock, Ordering::SeqCst);
let data = match &*self.state.lock().expect("state lock poisoned") {
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
RecoveryReadState::Blob(data) => data.clone(),
@@ -5850,325 +5779,6 @@ mod tests {
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
}
type ObservationEofGate = (Option<tokio::sync::oneshot::Sender<()>>, tokio::sync::oneshot::Receiver<()>);
struct ObservationStream {
inner: Cursor<Vec<u8>>,
consumed: Arc<AtomicUsize>,
fail: bool,
eof_gate: Option<ObservationEofGate>,
}
impl AsyncRead for ObservationStream {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
if self.fail {
return Poll::Ready(Err(std::io::Error::other("observation stream failed")));
}
if self.inner.position() == u64::try_from(self.inner.get_ref().len()).expect("fixture length")
&& let Some((ready, release)) = self.eof_gate.as_mut()
{
if let Some(ready) = ready.take() {
ready.send(()).expect("EOF observer remains active");
}
match std::future::Future::poll(Pin::new(release), cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(result) => result.expect("EOF gate released"),
}
self.eof_gate = None;
}
let before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
self.consumed.fetch_add(buf.filled().len() - before, Ordering::SeqCst);
result
}
}
struct ObservationStore {
body: Vec<u8>,
declared_size: i64,
error: Option<Error>,
stream_error: bool,
gets: AtomicUsize,
eof_gate: std::sync::Mutex<Option<ObservationEofGate>>,
consumed: Arc<AtomicUsize>,
options: std::sync::Mutex<Option<(bool, bool, bool)>>,
writes: AtomicUsize,
}
impl Debug for ObservationStore {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObservationStore").finish()
}
}
impl ObservationStore {
fn new(body: Vec<u8>, declared_size: i64) -> Self {
Self {
body,
declared_size,
error: None,
stream_error: false,
gets: AtomicUsize::new(0),
eof_gate: std::sync::Mutex::new(None),
consumed: Arc::new(AtomicUsize::new(0)),
options: std::sync::Mutex::new(None),
writes: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for ObservationStore {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
_headers: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
assert_eq!(bucket, RUSTFS_META_BUCKET);
assert_eq!(object, "config/observation.bin");
self.gets.fetch_add(1, Ordering::SeqCst);
assert!(range.is_none());
*self.options.lock().expect("observation options") =
Some((opts.suppress_read_repair, opts.no_lock, opts.skip_verify_bitrot));
if let Some(error) = &self.error {
return Err(error.clone());
}
Ok(GetObjectReader {
stream: Box::new(ObservationStream {
inner: Cursor::new(self.body.clone()),
consumed: self.consumed.clone(),
fail: self.stream_error,
eof_gate: self.eof_gate.lock().expect("EOF gate").take(),
}),
object_info: ObjectInfo {
size: self.declared_size,
etag: Some("observation-etag".to_string()),
..Default::default()
},
buffered_body: None,
body_source: Default::default(),
})
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut PutObjReader,
_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
self.writes.fetch_add(1, Ordering::SeqCst);
Err(Error::other("observation must not write"))
}
}
#[tokio::test]
async fn locked_observation_preserves_options_and_typed_errors() {
let object = "config/observation.bin";
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-options".to_owned(), manager.clone()),
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
"locked-observation-options".to_owned(),
);
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("matching real owner");
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
assert!(result.is_ok(), "I06L_READ_FLAGS: {result:?}");
assert!(*store.options.lock().expect("options") == Some((true, true, false)), "I06L_READ_FLAGS");
assert_eq!(result.expect("raw observation").0, b"raw");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
for (bucket, key) in [(RUSTFS_META_BUCKET, "config/other.bin"), ("wrong-bucket", object)] {
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-target".to_owned(), manager.clone()),
rustfs_lock::ObjectKey::new(bucket, key),
"locked-observation-target".to_owned(),
);
let wrong = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("wrong-target real owner");
let store = Arc::new(ObservationStore::new(Vec::new(), 0));
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &wrong).await;
assert!(matches!(result, Err(Error::InvalidArgument(..))), "I06L_TARGET_REJECTED");
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_TARGET_REJECTED");
}
for error in [
Error::FileNotFound,
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_owned(), object.to_owned()),
Error::ErasureReadQuorum,
] {
let expected = if error == Error::FileNotFound || matches!(error, Error::ObjectNotFound(..)) {
Error::ConfigNotFound
} else {
error.clone()
};
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.error = Some(error);
let store = Arc::new(fixture);
assert_eq!(
super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
.await
.expect_err("typed error"),
expected
);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.stream_error = true;
let store = Arc::new(fixture);
let err = super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
.await
.expect_err("stream failure");
assert!(err.to_string().contains("observation stream failed"), "I06L_TYPED_STREAM_ERROR");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &guard).await;
assert!(matches!(result, Err(Error::CorruptedFormat)), "I06L_STREAM_BOUND");
assert!(store.consumed.load(Ordering::SeqCst) == 9, "I06L_STREAM_BOUND");
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn locked_observation_rejects_actual_owner_loss_before_and_after_read() {
let object = "config/observation.bin";
for after_read in [false, true] {
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let client = Arc::new(RefreshControlledClient::new(manager));
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
rustfs_lock::NamespaceLock::new("locked-observation-loss".to_owned(), client.clone()),
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
"locked-observation-loss".to_owned(),
);
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
.await
.expect("real distributed owner");
let signal = guard.lock_lost_signal().expect("acquired distributed owner signal");
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
if after_read {
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
*store.eof_gate.lock().expect("EOF gate") = Some((Some(ready_tx), release_rx));
let mut read = Box::pin(super::read_config_observation_no_lock(store.clone(), object, 3, &guard));
tokio::time::timeout(std::time::Duration::from_secs(60), async {
tokio::select! {
ready = ready_rx => ready.expect("read reached EOF"),
result = &mut read => assert_eq!(false, true, "I06L_LOSS_AFTER_READ: returned before EOF gate: {result:?}"),
}
}).await.expect("EOF watchdog");
client.reject_refreshes();
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
.await
.expect("actual owner loss watchdog");
assert!(guard.is_lock_lost(), "I06L_LOSS_AFTER_READ");
release_tx.send(()).expect("release EOF");
let result = tokio::time::timeout(std::time::Duration::from_secs(60), read)
.await
.expect("read watchdog");
assert!(
matches!(
result,
Err(Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
..
})
),
"I06L_LOSS_AFTER_READ"
);
assert_eq!(store.gets.load(Ordering::SeqCst), 1);
} else {
client.reject_refreshes();
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
.await
.expect("actual owner loss watchdog");
assert!(guard.is_lock_lost(), "I06L_LOSS_BEFORE_GET");
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
assert!(
matches!(
result,
Err(Error::NamespaceLockQuorumUnavailable {
mode: "locked_config_observation",
..
})
),
"I06L_LOSS_BEFORE_GET"
);
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_LOSS_BEFORE_GET");
}
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn observation_preserves_options_and_errors() {
for body in [Vec::new(), b"raw config".to_vec()] {
let store = Arc::new(ObservationStore::new(body.clone(), i64::try_from(body.len()).expect("fixture size")));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
assert!(result.is_ok(), "observation must preserve empty and nonempty objects: {result:?}");
let (observed, info) = result.expect("successful observation");
assert_eq!(observed, body);
assert_eq!(info.etag.as_deref(), Some("observation-etag"));
assert_eq!(
*store.options.lock().expect("options"),
Some((true, false, false)),
"I05_REPAIR_SUPPRESSED"
);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
for (source, expected) in [
(Error::FileNotFound, Error::ConfigNotFound),
(
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_string(), "config/observation.bin".to_string()),
Error::ConfigNotFound,
),
(Error::ErasureReadQuorum, Error::ErasureReadQuorum),
] {
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.error = Some(source);
let store = Arc::new(fixture);
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
assert_eq!(result.expect_err("read error must propagate"), expected);
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
let mut fixture = ObservationStore::new(Vec::new(), 0);
fixture.stream_error = true;
let store = Arc::new(fixture);
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
let error = result.expect_err("stream error must propagate");
assert!(error.to_string().contains("observation stream failed"));
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn observation_enforces_byte_limit() {
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
assert!(result.is_err(), "I05_BOUND_ENFORCED");
assert_eq!(
result.expect_err("bounded observation error"),
Error::CorruptedFormat,
"I05_BOUND_ENFORCED"
);
assert_eq!(store.consumed.load(Ordering::SeqCst), 9, "I05_BOUND_ENFORCED: drain exactly limit + 1");
let store = Arc::new(ObservationStore::new(vec![7; 8], 8));
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
assert!(result.is_ok(), "exact byte limit must succeed: {result:?}");
assert_eq!(result.expect("exact limit").0, vec![7; 8]);
assert_eq!(store.consumed.load(Ordering::SeqCst), 8);
}
#[tokio::test]
async fn read_config_preserve_empty_distinguishes_empty_object() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
@@ -6189,60 +5799,6 @@ mod tests {
assert!(data.is_empty());
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_empty_and_options() {
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
for no_lock in [false, true] {
let opts = ObjectOptions {
no_lock,
..Default::default()
};
let (data, info) = read_config_preserve_empty_with_metadata_opts(store.clone(), "config/empty.json", &opts)
.await
.expect("an existing empty config must retain its payload and metadata");
assert!(data.is_empty());
assert_eq!(info.size, 0);
assert_eq!(info.actual_size, 0);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
assert_eq!(store.last_get_no_lock.load(Ordering::SeqCst), no_lock);
}
let err = read_config(store, "config/empty.json")
.await
.expect_err("the existing reader must still treat empty configs as missing");
assert!(matches!(err, Error::ConfigNotFound));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_keeps_payload_and_metadata() {
let payload = b"original config bytes".to_vec();
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(payload.clone()), None));
let (data, info) =
read_config_preserve_empty_with_metadata_opts(store, "config/nonempty.json", &ObjectOptions::default())
.await
.expect("a nonempty config must retain its payload and metadata");
assert_eq!(data, payload);
let size = i64::try_from(payload.len()).expect("test payload size fits i64");
assert_eq!(info.size, size);
assert_eq!(info.actual_size, size);
assert_eq!(info.etag.as_deref(), Some("config-1"));
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
}
#[tokio::test]
async fn read_config_preserve_empty_metadata_preserves_read_errors() {
let missing = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
let err = read_config_preserve_empty_with_metadata_opts(missing, "config/missing.json", &ObjectOptions::default())
.await
.expect_err("physical absence must remain ConfigNotFound");
assert!(matches!(err, Error::ConfigNotFound));
let quorum = Arc::new(RecoveryMockStore::new(RecoveryReadState::QuorumError, None));
let err = read_config_preserve_empty_with_metadata_opts(quorum, "config/quorum.json", &ObjectOptions::default())
.await
.expect_err("a quorum failure must retain its typed read error");
assert!(matches!(err, Error::ErasureReadQuorum));
}
#[async_trait::async_trait]
impl StorageAdminApi for RecoveryMockStore {
type BackendInfo = rustfs_madmin::BackendInfo;
+4 -5
View File
@@ -17076,7 +17076,6 @@ impl ECStore {
&[],
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: None,
lifecycle_owner: None,
lifecycle_guard: None,
namespace_lock_lost_signal: pool_meta_guard.lock_lost_signal(),
object_mutation_fence: Some(&object_fence),
@@ -17345,7 +17344,7 @@ impl ECStore {
let operation_gate = self.ctx.data_movement_operation_gate();
let bucket_incarnation_fence = match expected_bucket_incarnation_id {
Some(expected) => Some(Arc::new(self.acquire_bucket_incarnation_fence(&bucket, expected).await?)),
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
None => None,
};
@@ -18281,8 +18280,9 @@ impl ECStore {
&cleanup_transferred_tier_versions,
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: expected_bucket_incarnation_id,
lifecycle_owner: bucket_incarnation_fence.as_ref(),
lifecycle_guard: None,
lifecycle_guard: bucket_incarnation_fence
.as_ref()
.and_then(|guard| guard.namespace_lock_guard()),
namespace_lock_lost_signal: None,
object_mutation_fence: Some(&source_cleanup_mutation_fence),
},
@@ -29408,7 +29408,6 @@ mod pools_tests {
pool_meta_save_gate: tokio::sync::Mutex::new(super::PoolMetaWriteState::for_test_bootstrap()),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
})
}
-1
View File
@@ -499,7 +499,6 @@ mod decommission_lock_order_tests {
),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
});
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
(store, refresh_calls)
-31
View File
@@ -99,33 +99,6 @@ pub struct Sets {
ctx: Arc<InstanceContext>,
}
#[cfg(test)]
impl Sets {
pub(crate) fn from_test_set(
set: Arc<SetDisks>,
endpoints: PoolEndpoints,
format: FormatV3,
pool_idx: usize,
parity_count: usize,
ctx: Arc<InstanceContext>,
) -> Arc<Self> {
Arc::new(Self {
id: format.id,
disk_set: vec![set],
pool_idx,
set_count: 1,
set_drive_count: endpoints.drives_per_set,
endpoints,
format,
parity_count,
default_parity_count: parity_count,
distribution_algo: DistributionAlgoVersion::V1,
exit_signal: None,
ctx,
})
}
}
impl Drop for Sets {
fn drop(&mut self) {
if let Some(exit_signal) = self.exit_signal.take() {
@@ -827,10 +800,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
mod_time: dst_opts.mod_time,
http_preconditions: dst_opts.http_preconditions.clone(),
quota_admission: dst_opts.quota_admission,
expected_bucket_incarnation_id: dst_opts.expected_bucket_incarnation_id,
bucket_lifecycle_lock_fence: dst_opts.bucket_lifecycle_lock_fence.clone(),
bucket_incarnation_fence: dst_opts.bucket_incarnation_fence.clone(),
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
..Default::default()
};
+4 -9
View File
@@ -31,7 +31,7 @@ use crate::storage_api_contracts::{
namespace::NamespaceLocking as _,
object::{HTTPPreconditions, ObjectOperations as _},
};
use crate::store::{BucketIncarnationFenceGuard, DecommissionFixedReadAnchor, ECStore, SourceCleanupMutationFence};
use crate::store::{DecommissionFixedReadAnchor, ECStore, SourceCleanupMutationFence};
use bytes::Bytes;
use rustfs_filemeta::{FileInfo, FileInfoVersions, ObjectPartInfo};
use rustfs_rio::{EtagResolvable, HashReader, HashReaderDetector, Index, TryGetIndex};
@@ -1278,7 +1278,6 @@ pub(crate) enum SourceCleanupError {
#[derive(Clone, Default)]
pub(crate) struct SourceCleanupBucketFence<'a> {
pub(crate) expected_incarnation_id: Option<uuid::Uuid>,
pub(crate) lifecycle_owner: Option<&'a Arc<BucketIncarnationFenceGuard>>,
pub(crate) lifecycle_guard: Option<&'a rustfs_lock::NamespaceLockGuard>,
pub(crate) namespace_lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
pub(crate) object_mutation_fence: Option<&'a SourceCleanupMutationFence>,
@@ -1487,10 +1486,9 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
)
};
if bucket_fence.lifecycle_owner.is_some_and(|owner| owner.is_lock_lost())
|| bucket_fence
.lifecycle_guard
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
if bucket_fence
.lifecycle_guard
.is_some_and(rustfs_lock::NamespaceLockGuard::is_lock_lost)
{
return Err(SourceCleanupError::Storage(Error::other(format!(
"{op_label}: bucket incarnation fence was lost before source cleanup"
@@ -1529,9 +1527,6 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
if let Some(object_mutation_fence) = bucket_fence.object_mutation_fence {
object_mutation_fence.add_namespace_lock_fence(&mut opts);
}
if let Some(owner) = bucket_fence.lifecycle_owner {
owner.attach_owner(&mut opts);
}
if let Some(bucket_lifecycle_guard) = bucket_fence.lifecycle_guard {
opts.add_bucket_lifecycle_lock_guard(bucket_lifecycle_guard);
}
+25 -74
View File
@@ -216,7 +216,7 @@ pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket:
// Stale or empty cache: refresh through the TTL-bounded loader. A failed
// refresh carries the previous baseline forward, so quota admission keeps
// its last grounded values through a backend read outage.
let _ = project_data_usage_from_backend_cached(store, |_| ()).await;
let _ = load_data_usage_from_backend_cached(store).await;
let cache = data_usage_snapshot_cache().read().await;
cache
.as_ref()
@@ -237,34 +237,31 @@ struct CachedDataUsageSnapshot {
}
impl CachedDataUsageSnapshot {
fn result<T>(&self, project: impl FnOnce(&DataUsageInfo) -> T) -> Result<T, Error> {
fn result(&self) -> Result<DataUsageInfo, Error> {
self.info
.as_ref()
.map(project)
.clone()
.ok_or_else(|| Error::other("data usage snapshot load recently failed"))
}
}
fn fresh_cached_data_usage_snapshot<T>(
fn fresh_cached_data_usage_snapshot(
cache: &Option<CachedDataUsageSnapshot>,
now: tokio::time::Instant,
ttl: Duration,
project: impl FnOnce(&DataUsageInfo) -> T,
) -> Option<Result<T, Error>> {
) -> Option<Result<DataUsageInfo, Error>> {
cache
.as_ref()
.filter(|cached| now.duration_since(cached.loaded_at) < ttl)
.map(|cached| cached.result(project))
.map(CachedDataUsageSnapshot::result)
}
fn cache_data_usage_snapshot_result<T>(
fn cache_data_usage_snapshot_result(
cache: &mut Option<CachedDataUsageSnapshot>,
result: Result<LoadedUsageBaseline, Error>,
loaded_at: tokio::time::Instant,
refresh_generation: u64,
current_generation: u64,
project: impl FnOnce(&DataUsageInfo) -> T,
) -> Option<Result<T, Error>> {
) -> Option<Result<DataUsageInfo, Error>> {
if current_generation != refresh_generation {
return None;
}
@@ -283,13 +280,12 @@ fn cache_data_usage_snapshot_result<T>(
degraded_baseline.entry(bucket.clone()).or_insert(*size);
}
}
let projected = project(&info);
*cache = Some(CachedDataUsageSnapshot {
info: Some(info),
info: Some(info.clone()),
loaded_at,
degraded_baseline,
});
Ok(projected)
Ok(info)
}
Err(e) => {
// Keep the previous baseline through a failed refresh: quota
@@ -1490,21 +1486,12 @@ async fn normalize_loaded_data_usage(
/// `save_data_usage_in_backend` invalidates the cache so a fresh scanner save
/// is visible immediately in the saving process.
pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
project_data_usage_from_backend_cached(store, DataUsageInfo::clone).await
}
// Project under the cache guard so scalar readers never copy the snapshot's
// bucket maps. The owned API shares the same TTL, refresh and invalidation path.
async fn project_data_usage_from_backend_cached<T>(
store: Arc<ECStore>,
project: impl Fn(&DataUsageInfo) -> T,
) -> Result<T, Error> {
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
loop {
{
let cache = data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
return result;
}
}
@@ -1516,7 +1503,7 @@ async fn project_data_usage_from_backend_cached<T>(
.await;
{
let cache = data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
return result;
}
}
@@ -1525,14 +1512,9 @@ async fn project_data_usage_from_backend_cached<T>(
let result = load_data_usage_from_backend_with_baseline(store.clone()).await;
let loaded_at = tokio::time::Instant::now();
let mut cache = data_usage_snapshot_cache().write().await;
if let Some(result) = cache_data_usage_snapshot_result(
&mut cache,
result,
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
&project,
) {
if let Some(result) =
cache_data_usage_snapshot_result(&mut cache, result, loaded_at, refresh_generation, data_usage_snapshot_generation())
{
return result;
}
drop(cache);
@@ -1550,8 +1532,7 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
loop {
{
let cache = admin_data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
{
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
return result;
}
}
@@ -1562,8 +1543,7 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
.await;
{
let cache = admin_data_usage_snapshot_cache().read().await;
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
{
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
return result;
}
}
@@ -1584,7 +1564,6 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
loaded_at,
refresh_generation,
admin_data_usage_snapshot_generation(),
DataUsageInfo::clone,
) {
return result;
}
@@ -2298,10 +2277,8 @@ async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
}
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
project_data_usage_from_backend_cached(store, |info| info.buckets_usage.get(bucket).map(|usage| usage.size))
.await
.ok()
.flatten()
let info = load_data_usage_from_backend_cached(store).await.ok()?;
info.buckets_usage.get(bucket).map(|usage| usage.size)
}
async fn reload_persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
@@ -2398,7 +2375,6 @@ where
tokio::time::Instant::now(),
refresh_generation,
data_usage_snapshot_generation(),
|_| (),
)
.is_some()
}
@@ -2915,8 +2891,6 @@ pub async fn init_compression_total_memory_from_backend(store: Arc<ECStore>) {
#[cfg(test)]
mod tests {
mod snapshot_projection;
use super::*;
use crate::layout::endpoints::EndpointServerPools;
use crate::runtime::instance::InstanceContext;
@@ -3360,7 +3334,6 @@ mod tests {
pool_meta_save_gate: TokioMutex::default(),
ctx,
bucket_fence_registry: Arc::default(),
ordinary_classification: Default::default(),
})
}
@@ -4599,29 +4572,15 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
)
.expect("an uninterrupted refresh should populate the cache");
assert!(matches!(first, Err(Error::ErasureReadQuorum)));
let cached = fresh_cached_data_usage_snapshot(
&cache,
loaded_at + Duration::from_secs(1),
Duration::from_secs(30),
DataUsageInfo::clone,
)
.expect("the failed load must remain cached within the TTL");
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
.expect("the failed load must remain cached within the TTL");
assert!(cached.is_err());
assert!(
fresh_cached_data_usage_snapshot(
&cache,
loaded_at + Duration::from_secs(30),
Duration::from_secs(30),
DataUsageInfo::clone
)
.is_none()
);
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(30), Duration::from_secs(30)).is_none());
}
#[test]
@@ -4642,20 +4601,14 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
)
.expect("an uninterrupted refresh should populate the cache")
.expect("successful load must be returned");
assert_snapshot_bucket(&first, "bucket");
let cached = fresh_cached_data_usage_snapshot(
&cache,
loaded_at + Duration::from_secs(1),
Duration::from_secs(30),
DataUsageInfo::clone,
)
.expect("successful load must remain cached within the TTL")
.expect("cached success must remain successful");
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
.expect("successful load must remain cached within the TTL")
.expect("cached success must remain successful");
assert_snapshot_bucket(&cached, "bucket");
}
@@ -4680,7 +4633,6 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
)
.expect("an uninterrupted refresh should populate the cache")
.expect("successful load must be returned");
@@ -4714,7 +4666,6 @@ mod tests {
loaded_at,
refresh_generation,
data_usage_snapshot_generation(),
DataUsageInfo::clone,
);
assert!(stale_result.is_none());
@@ -1,191 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::*;
#[test]
fn scalar_projection_borrows_snapshot_and_preserves_unknown_and_zero() {
let loaded_at = tokio::time::Instant::now();
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
let cache = Some(CachedDataUsageSnapshot {
info: Some(info),
loaded_at,
degraded_baseline: HashMap::new(),
});
let stored = cache
.as_ref()
.expect("cached snapshot")
.info
.as_ref()
.expect("known snapshot");
let ttl = Duration::from_secs(30);
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
let value = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, |info| {
assert!(std::ptr::eq(info, stored), "projection must borrow the cached snapshot");
info.buckets_usage.get(bucket).map(|usage| usage.size)
})
.expect("fresh snapshot")
.expect("successful snapshot");
let owned = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, DataUsageInfo::clone)
.expect("fresh snapshot")
.expect("successful snapshot");
assert_eq!(value, expected);
assert_eq!(value, owned.buckets_usage.get(bucket).map(|usage| usage.size));
}
}
#[test]
fn projection_is_skipped_for_missing_failed_expired_or_invalidated_snapshots() {
let loaded_at = tokio::time::Instant::now();
let ttl = Duration::from_secs(30);
let never_project = |_: &DataUsageInfo| -> u64 { panic!("unusable snapshots must not be projected") };
assert!(fresh_cached_data_usage_snapshot(&None, loaded_at, ttl, never_project).is_none());
let mut cache = None;
let failure = cache_data_usage_snapshot_result(&mut cache, Err(Error::ErasureReadQuorum), loaded_at, 1, 1, never_project)
.expect("matching generation");
assert!(matches!(failure, Err(Error::ErasureReadQuorum)));
assert!(
fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, never_project)
.expect("negative cache remains fresh")
.is_err()
);
cache = Some(CachedDataUsageSnapshot {
info: Some(data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH)),
loaded_at,
degraded_baseline: HashMap::new(),
});
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + ttl, ttl, never_project).is_none());
cache = None;
let rejected = cache_data_usage_snapshot_result(
&mut cache,
Ok(LoadedUsageBaseline {
info: data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH),
degraded_baseline: HashMap::new(),
observed_unavailable: false,
}),
loaded_at,
1,
2,
never_project,
);
assert!(rejected.is_none());
assert!(cache.is_none(), "rejected generation must not republish stale data");
}
#[test]
fn projected_publication_moves_bucket_maps_into_cache() {
let info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
let original_key = info.buckets_usage.get_key_value("known").expect("source bucket").0.as_ptr();
let mut cache = None;
let usage = cache_data_usage_snapshot_result(
&mut cache,
Ok(LoadedUsageBaseline {
info,
degraded_baseline: HashMap::new(),
observed_unavailable: false,
}),
tokio::time::Instant::now(),
1,
1,
|info| {
assert_eq!(
info.buckets_usage
.get_key_value("known")
.expect("projected bucket")
.0
.as_ptr(),
original_key
);
info.buckets_usage.get("known").map(|usage| usage.size)
},
)
.expect("matching generation")
.expect("successful publication");
assert_eq!(usage, Some(42));
let cached = cache
.as_ref()
.expect("published cache")
.info
.as_ref()
.expect("published snapshot");
assert_eq!(
cached.buckets_usage.get_key_value("known").expect("cached bucket").0.as_ptr(),
original_key
);
}
#[tokio::test]
#[serial]
async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
info.bucket_sizes.insert("empty".to_string(), 0);
info.buckets_count = 2;
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&info).expect("encode snapshot"),
)
.await
.expect("persist snapshot");
invalidate_data_usage_snapshot_cache().await;
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
assert_eq!(persisted_bucket_usage(store.clone(), bucket).await, expected);
let owned = load_data_usage_from_backend_cached(store.clone())
.await
.expect("owned cached snapshot");
assert_eq!(expected, owned.buckets_usage.get(bucket).map(|usage| usage.size));
}
info.buckets_usage.get_mut("known").expect("known bucket").size = 84;
info.bucket_sizes.insert("known".to_string(), 84);
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&info).expect("encode new snapshot"),
)
.await
.expect("persist new snapshot without invalidating cache");
assert_eq!(
persisted_bucket_usage(store.clone(), "known").await,
Some(42),
"warm reads reuse the TTL cache"
);
invalidate_data_usage_snapshot_cache().await;
assert_eq!(
persisted_bucket_usage(store.clone(), "known").await,
Some(84),
"invalidation exposes the new snapshot"
);
invalidate_data_usage_snapshot_cache().await;
if let Some(cancel) = store.ctx.background_cancel_token() {
cancel.cancel();
}
for pool in &store.pools {
for set in &pool.disk_set {
let disks = set.disks.read().await.clone();
for disk in disks.iter().flatten() {
disk.close().await.expect("stop fixture disk monitoring");
}
}
}
}
+48 -48
View File
@@ -624,58 +624,58 @@ mod tests {
#[tokio::test]
async fn cleanup_dispatch_retains_resource_units_without_inheriting_disk_policy() {
use crate::disk::io_admission::{current_disk_io_context, with_background_disk_io, with_background_resource_unit};
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
use crate::disk::io_admission::{current_disk_io_context, test_resource_units, with_background_disk_io};
use tokio_util::sync::CancellationToken;
for mode in 0..4 {
let runtime = Arc::new(CleanupRuntime::new(config(1, 1, 1)).expect("cleanup runtime"));
let worker_runtime = runtime.clone();
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let work = async move {
started_tx
.send((current_resource_unit().is_some(), current_disk_io_context().is_none()))
.expect("report worker context");
release_rx.await.expect("release owned worker");
Ok::<_, super::super::error::DiskError>(())
};
let task = tokio::spawn(with_background_disk_io(
CancellationToken::new(),
with_background_resource_unit(gate.acquire().await, async move {
match mode {
0 => worker_runtime
.disk_operation(work, DiskAdmission::Request)
.await
.expect("disk operation"),
1 => worker_runtime.gc_operation(move |_| work).await.expect("GC operation"),
2 => {
let reservation = worker_runtime.reserve().expect("cleanup reservation");
run(Some(reservation), async { work.await.expect("cleanup operation") }).await;
for physical_count in [1, 2] {
for mode in 0..4 {
let runtime = Arc::new(CleanupRuntime::new(config(1, 1, 1)).expect("cleanup runtime"));
let worker_runtime = runtime.clone();
let (unit, gates) = test_resource_units(physical_count);
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let work = async move {
started_tx
.send((current_resource_unit().is_some(), current_disk_io_context().is_none()))
.expect("report worker context");
release_rx.await.expect("release owned worker");
Ok::<_, super::super::error::DiskError>(())
};
let task = tokio::spawn(with_background_disk_io(
CancellationToken::new(),
with_optional_resource_unit(Some(unit), async move {
match mode {
0 => worker_runtime
.disk_operation(work, DiskAdmission::Request)
.await
.expect("disk operation"),
1 => worker_runtime.gc_operation(move |_| work).await.expect("GC operation"),
2 => {
let reservation = worker_runtime.reserve().expect("cleanup reservation");
run(Some(reservation), async { work.await.expect("cleanup operation") }).await;
}
_ => spawn(None, work)
.await
.expect("unisolated worker")
.expect("unisolated operation"),
}
_ => spawn(None, work)
.await
.expect("unisolated worker")
.expect("unisolated operation"),
}),
));
let (inherited, original_policy) = deadline(started_rx).await.expect("owned work starts");
task.abort();
assert!(task.await.expect_err("waiter cancelled").is_cancelled());
let held: Vec<_> = gates.iter().map(|gate| gate.snapshot().active).collect();
release_tx.send(()).expect("owned work survives cancellation");
deadline(async {
while gates.iter().any(|gate| gate.snapshot().active != 0) {
tokio::task::yield_now().await;
}
}),
));
let (inherited, original_policy) = deadline(started_rx).await.expect("owned work starts");
task.abort();
assert!(task.await.expect_err("waiter cancelled").is_cancelled());
let held = gate.snapshot().active;
release_tx.send(()).expect("owned work survives cancellation");
deadline(async {
while gate.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await;
assert!(inherited, "mode {mode} lost resource ownership");
assert!(original_policy, "mode {mode} changed the detached worker's disk policy");
assert_eq!(held, 1, "mode {mode} released a live worker's unit");
})
.await;
assert!(inherited, "mode {mode} lost resource ownership");
assert!(original_policy, "mode {mode} changed the detached worker's disk policy");
assert_eq!(held, vec![1; physical_count], "mode {mode} released a live worker's unit");
}
}
}
fn config(pending: usize, workers: usize, blocking_threads: usize) -> Config {
File diff suppressed because it is too large Load Diff
+160 -60
View File
@@ -16,40 +16,15 @@ use std::{
collections::HashMap,
fs::Metadata,
path::{Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
sync::Mutex,
time::{Duration, Instant},
};
use tokio::{
fs::{self, File},
io,
};
use tokio::{fs::File, io};
static READONLY_OPTIONS: OnceLock<Arc<fs::OpenOptions>> = OnceLock::new();
static WRITEONLY_OPTIONS: OnceLock<Arc<fs::OpenOptions>> = OnceLock::new();
static READWRITE_OPTIONS: OnceLock<Arc<fs::OpenOptions>> = OnceLock::new();
fn get_readonly_options() -> &'static Arc<fs::OpenOptions> {
READONLY_OPTIONS.get_or_init(|| {
let mut opts = fs::OpenOptions::new();
opts.read(true);
Arc::new(opts)
})
}
fn get_writeonly_options() -> &'static Arc<fs::OpenOptions> {
WRITEONLY_OPTIONS.get_or_init(|| {
let mut opts = fs::OpenOptions::new();
opts.write(true);
Arc::new(opts)
})
}
fn get_readwrite_options() -> &'static Arc<fs::OpenOptions> {
READWRITE_OPTIONS.get_or_init(|| {
let mut opts = fs::OpenOptions::new();
opts.read(true).write(true);
Arc::new(opts)
})
async fn native_io<T: Send + 'static>(operation: impl FnOnce() -> io::Result<T> + Send + 'static) -> io::Result<T> {
super::io_admission::spawn_blocking_with_resource_ownership(operation)
.await
.map_err(|_| io::Error::other("background task failed"))?
}
#[cfg(not(windows))]
@@ -117,33 +92,32 @@ pub const O_APPEND: FileMode = 0x00400;
// create_new: bool,
pub async fn open_file(path: impl AsRef<Path>, mode: FileMode) -> io::Result<File> {
let base_opts = match mode & (O_RDONLY | O_WRONLY | O_RDWR) {
O_RDONLY => get_readonly_options(),
O_WRONLY => get_writeonly_options(),
O_RDWR => get_readwrite_options(),
_ => get_readonly_options(),
};
if (mode & (O_CREATE | O_APPEND | O_TRUNC)) != 0 {
let mut opts = (**base_opts).clone();
if mode & O_CREATE != 0 {
opts.create(true);
let path = path.as_ref().to_path_buf();
native_io(move || {
let mut options = std::fs::OpenOptions::new();
match mode & (O_RDONLY | O_WRONLY | O_RDWR) {
O_WRONLY => options.write(true),
O_RDWR => options.read(true).write(true),
_ => options.read(true),
}
if mode & O_APPEND != 0 {
opts.append(true);
.create(mode & O_CREATE != 0)
.append(mode & O_APPEND != 0)
.truncate(mode & O_TRUNC != 0);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
if mode == O_RDONLY {
super::os::prepared_publication_test_hooks::run(
super::os::prepared_publication_test_hooks::Stage::NativeReadOpen,
&path,
);
}
if mode & O_TRUNC != 0 {
opts.truncate(true);
}
opts.open(path.as_ref()).await
} else {
base_opts.open(path.as_ref()).await
}
options.open(path).map(File::from_std)
})
.await
}
pub async fn access(path: impl AsRef<Path>) -> io::Result<()> {
fs::metadata(path).await?;
Ok(())
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::metadata(path).map(|_| ())).await
}
pub fn access_std(path: impl AsRef<Path>) -> io::Result<()> {
@@ -152,13 +126,19 @@ pub fn access_std(path: impl AsRef<Path>) -> io::Result<()> {
}
pub async fn lstat(path: impl AsRef<Path>) -> io::Result<Metadata> {
fs::symlink_metadata(path).await
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::symlink_metadata(path)).await
}
pub fn lstat_std(path: impl AsRef<Path>) -> io::Result<Metadata> {
std::fs::symlink_metadata(path)
}
pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::create_dir_all(path)).await
}
fn is_dir_error(e: &io::Error) -> bool {
e.raw_os_error() == Some(libc::EISDIR)
|| e.kind() == io::ErrorKind::IsADirectory
@@ -169,9 +149,13 @@ fn is_dir_error(e: &io::Error) -> bool {
#[tracing::instrument(level = "debug", skip_all)]
pub async fn remove(path: impl AsRef<Path>) -> io::Result<()> {
// Try remove_file first; fall back to remove_dir if it's a directory
match fs::remove_file(path.as_ref()).await {
let file_path = path.as_ref().to_path_buf();
match native_io(move || std::fs::remove_file(file_path)).await {
Ok(()) => Ok(()),
Err(e) if is_dir_error(&e) => fs::remove_dir(path.as_ref()).await,
Err(e) if is_dir_error(&e) => {
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::remove_dir(path)).await
}
Err(e) => Err(e),
}
}
@@ -179,9 +163,13 @@ pub async fn remove(path: impl AsRef<Path>) -> io::Result<()> {
#[allow(dead_code, reason = "asserted by this file's tests (backlog#1823)")]
pub async fn remove_all(path: impl AsRef<Path>) -> io::Result<()> {
// Try remove_file first; fall back to remove_dir_all if it's a directory
match fs::remove_file(path.as_ref()).await {
let file_path = path.as_ref().to_path_buf();
match native_io(move || std::fs::remove_file(file_path)).await {
Ok(()) => Ok(()),
Err(e) if is_dir_error(&e) => fs::remove_dir_all(path.as_ref()).await,
Err(e) if is_dir_error(&e) => {
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::remove_dir_all(path)).await
}
Err(e) => Err(e),
}
}
@@ -206,8 +194,15 @@ pub fn remove_all_std(path: impl AsRef<Path>) -> io::Result<()> {
}
}
pub async fn mkdir(path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref().to_path_buf();
native_io(move || std::fs::create_dir(path)).await
}
pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
fs::rename(from, to).await
let from = from.as_ref().to_path_buf();
let to = to.as_ref().to_path_buf();
native_io(move || std::fs::rename(from, to)).await
}
#[cfg(any(not(windows), test))]
@@ -269,7 +264,7 @@ pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
return Err(io::Error::new(io::ErrorKind::NotFound, "bucket not found (cached)"));
}
let result = fs::metadata(&path_buf).await;
let result = access(&path_buf).await;
match &result {
Ok(_) => BUCKET_EXISTENCE_CACHE.record(path_buf, true),
@@ -293,6 +288,89 @@ mod tests {
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
#[test]
fn cancelled_filesystem_jobs_retain_physical_resources() {
use crate::disk::io_admission::{test_resource_units, with_optional_resource_unit};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.max_blocking_threads(1)
.build()
.expect("single native worker");
runtime.block_on(async {
let mut observations = Vec::new();
for count in 1..=2 {
for operation in [
"access",
"cached_access",
"lstat",
"mkdir",
"make_dir_all",
"remove",
"remove_all",
"rename",
"open",
] {
let dir = TempDir::new().expect("filesystem ownership fixture");
let source = dir.path().join("source");
let target = dir.path().join("target");
match operation {
"mkdir" | "make_dir_all" | "open" => {}
"remove_all" => {
std::fs::create_dir(&source).expect("recursive removal directory");
std::fs::write(source.join("child"), b"payload").expect("recursive removal child");
}
_ => std::fs::write(&source, b"payload").expect("existing file"),
}
let (resources, gates) = test_resource_units(count);
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let blocker = tokio::task::spawn_blocking(move || {
let _ = started_tx.send(());
let _ = release_rx.recv();
});
started_rx.await.expect("native worker occupied");
let mut future = Box::pin(with_optional_resource_unit(Some(resources), async {
match operation {
"access" => access(&source).await,
"cached_access" => cached_access(&source).await,
"lstat" => lstat(&source).await.map(|_| ()),
"mkdir" => mkdir(&source).await,
"make_dir_all" => make_dir_all(source.join("child")).await,
"remove" => remove(&source).await,
"remove_all" => remove_all(&source).await,
"rename" => rename(&source, &target).await,
"open" => open_file(&source, O_CREATE | O_WRONLY).await.map(|_| ()),
_ => unreachable!("fixed operation list"),
}
}));
assert!(futures::poll!(&mut future).is_pending(), "{operation}: native job queued");
drop(future);
let held: Vec<_> = gates.iter().map(|gate| gate.snapshot().active).collect();
release_tx.send(()).expect("release native worker before assertions");
blocker.await.expect("native blocker completed");
tokio::task::spawn_blocking(|| ()).await.expect("native queue drained");
assert!(gates.iter().all(|gate| gate.snapshot().active == 0));
match operation {
"mkdir" => assert!(source.is_dir()),
"make_dir_all" => assert!(source.join("child").is_dir()),
"open" => assert!(source.is_file()),
"remove" => assert!(!source.exists()),
"rename" => {
assert!(!source.exists());
assert_eq!(std::fs::read(&target).expect("renamed file"), b"payload");
}
_ => {}
}
observations.push((count, operation, held));
}
}
for (count, operation, held) in observations {
assert_eq!(held, vec![1; count], "{operation}: queued native job keeps its physical resources");
}
});
}
#[tokio::test]
async fn test_file_mode_constants() {
assert_eq!(O_RDONLY, 0x00000);
@@ -464,6 +542,17 @@ mod tests {
assert!(metadata.file_type().is_symlink());
}
#[tokio::test]
async fn test_make_dir_all() {
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("level1").join("level2").join("level3");
// Should create nested directories
assert!(make_dir_all(&nested_path).await.is_ok());
assert!(nested_path.exists());
assert!(nested_path.is_dir());
}
#[tokio::test]
async fn test_remove_file() {
let temp_dir = TempDir::new().unwrap();
@@ -536,6 +625,17 @@ mod tests {
assert!(!dir_path.exists());
}
#[tokio::test]
async fn test_mkdir() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path().join("test_mkdir");
// Create directory
assert!(mkdir(&dir_path).await.is_ok());
assert!(dir_path.exists());
assert!(dir_path.is_dir());
}
#[tokio::test]
async fn test_rename() {
let temp_dir = TempDir::new().unwrap();
+218 -15
View File
@@ -14,6 +14,18 @@
//! Per-local-disk foreground-priority admission for scanner I/O.
pub(crate) mod memory;
mod physical_identity;
mod physical_owners;
mod physical_sampler;
pub use memory::BackgroundMemoryBudget;
pub(crate) use physical_sampler::PhysicalHealBinding;
pub use physical_sampler::{
PhysicalDiskBinding, PhysicalDiskPlan, PhysicalDiskSample, PhysicalDiskSampler, PhysicalResourcePlan,
};
use std::collections::VecDeque;
use std::future::Future;
use std::io;
@@ -25,18 +37,118 @@ use std::time::Duration;
use metrics::{counter, gauge, histogram};
use rustfs_concurrency::adaptive::AdaptivePermit;
use rustfs_concurrency::adaptive::permit_set::AdaptivePermitSet;
use rustfs_rio::ChunkReader;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::Notify;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
pub(crate) mod remote;
pub use remote::{RemoteBackgroundIo, with_remote_background_io};
const BACKGROUND_FOREGROUND_BURST: u32 = 8;
const BACKGROUND_MAX_WAIT: Duration = Duration::from_millis(100);
tokio::task_local! {
static DISK_IO_CONTEXT: DiskIoContext;
static RESOURCE_IO_UNIT: AdaptivePermit;
static RESOURCE_IO_UNIT: ResourceIoUnit;
}
/// Native I/O retains the physical subset selected before storage work. A
/// layered disk may touch several leaves; unrelated devices stay outside it.
#[derive(Clone, Debug)]
pub(crate) struct ResourceIoUnit {
permits: ResourceIoPermits,
memory_budget: Option<Arc<BackgroundMemoryBudget>>,
#[cfg(target_os = "linux")]
_mount_lease: Option<Arc<std::fs::File>>,
}
#[derive(Debug)]
enum ResourceIoPermits {
Single(AdaptivePermit),
Set(AdaptivePermitSet),
}
impl Clone for ResourceIoPermits {
fn clone(&self) -> Self {
match self {
Self::Single(permit) => Self::Single(permit.clone()),
Self::Set(permits) => Self::Set(permits.clone()),
}
}
}
impl From<AdaptivePermit> for ResourceIoUnit {
fn from(permit: AdaptivePermit) -> Self {
Self {
permits: ResourceIoPermits::Single(permit),
memory_budget: BackgroundMemoryBudget::current(),
#[cfg(target_os = "linux")]
_mount_lease: None,
}
}
}
impl From<AdaptivePermitSet> for ResourceIoUnit {
fn from(permits: AdaptivePermitSet) -> Self {
Self {
permits: ResourceIoPermits::Set(permits),
memory_budget: BackgroundMemoryBudget::current(),
#[cfg(target_os = "linux")]
_mount_lease: None,
}
}
}
impl ResourceIoUnit {
pub(crate) fn memory_budget(&self) -> Option<Arc<BackgroundMemoryBudget>> {
self.memory_budget.clone()
}
pub(crate) fn with_permits(mut self, permits: AdaptivePermitSet) -> Self {
self.permits = ResourceIoPermits::Set(permits);
self
}
#[cfg(test)]
pub(crate) fn with_memory_budget(mut self, budget: Arc<BackgroundMemoryBudget>) -> Self {
self.memory_budget = Some(budget);
self
}
pub(crate) fn as_set(&self) -> Option<&AdaptivePermitSet> {
match &self.permits {
ResourceIoPermits::Single(_) => None,
ResourceIoPermits::Set(permits) => Some(permits),
}
}
#[cfg(target_os = "linux")]
pub(crate) fn with_mount_lease(mut self, mount_lease: Arc<std::fs::File>) -> Self {
// The selected disk's proc-fd paths must survive native jobs after its
// async wrapper retires. Clones carry the same pinned mount generation.
self._mount_lease = Some(mount_lease);
self
}
}
#[cfg(test)]
pub(crate) fn test_resource_units(count: usize) -> (ResourceIoUnit, Vec<Arc<rustfs_concurrency::adaptive::AdaptiveAdmission>>) {
use rustfs_concurrency::adaptive::{AdaptiveAdmission, permit_set::AdaptivePermitSetOwner};
use std::num::NonZeroU16;
assert!((1..=2).contains(&count), "test single and layered disks");
let gates: Vec<_> = (0..count).map(|_| AdaptiveAdmission::new(NonZeroU16::MIN, 1)).collect();
let mut admitted: Vec<_> = gates.iter().map(|gate| gate.try_acquire().expect("test resource")).collect();
let unit = if count == 1 {
ResourceIoUnit::from(admitted.pop().expect("single resource"))
} else {
let owner = AdaptivePermitSetOwner::new(NonZeroU16::MIN, gates.clone()).expect("layered resource");
ResourceIoUnit::from(owner.capture(&admitted.iter().collect::<Vec<_>>()).expect("admitted subset"))
};
(unit, gates)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -51,7 +163,7 @@ pub(crate) struct DiskIoContext {
pub(crate) cancellation: CancellationToken,
pub(crate) admission: Option<Arc<DiskIoAdmission>>,
pub(crate) permit: Option<DiskIoPermit>,
pub(crate) resource_unit: Option<AdaptivePermit>,
pub(crate) resource_unit: Option<ResourceIoUnit>,
}
/// Mark disk I/O issued by `future` as background work for local-disk admission.
@@ -78,24 +190,46 @@ pub async fn with_background_disk_io<F: Future>(cancellation: CancellationToken,
/// of a stalled native operation. Escaping buffers need their own memory leases.
#[doc(hidden)]
pub async fn with_background_resource_unit<F: Future>(unit: AdaptivePermit, future: F) -> F::Output {
RESOURCE_IO_UNIT.scope(unit, future).await
RESOURCE_IO_UNIT.scope(ResourceIoUnit::from(unit), future).await
}
pub(crate) fn current_resource_unit() -> Option<AdaptivePermit> {
RESOURCE_IO_UNIT.try_with(Clone::clone).ok().or_else(|| {
DISK_IO_CONTEXT
.try_with(|context| context.resource_unit.clone())
.ok()
.flatten()
})
/// Retain only the previously admitted physical leaves touched by this I/O.
/// Projection of owned units never waits and may run under storage locks.
/// Acquire the planned resources before locking; auxiliary leaves discovered
/// later must use nonblocking admission. Do not pass unrelated parent resources
/// to a native job that touches only some of its disks.
#[doc(hidden)]
pub async fn with_background_resource_set<F: Future>(units: AdaptivePermitSet, future: F) -> F::Output {
RESOURCE_IO_UNIT.scope(ResourceIoUnit::from(units), future).await
}
pub(crate) fn current_resource_unit() -> Option<ResourceIoUnit> {
RESOURCE_IO_UNIT
.try_with(Clone::clone)
.ok()
.or_else(|| {
DISK_IO_CONTEXT
.try_with(|context| context.resource_unit.clone())
.ok()
.flatten()
})
.or_else(remote::resources)
}
pub(crate) fn has_resource_io_scope() -> bool {
RESOURCE_IO_UNIT.try_with(|_| ()).is_ok()
|| DISK_IO_CONTEXT
.try_with(|context| context.resource_unit.is_some())
.unwrap_or(false)
|| remote::active()
}
/// Transfer only resource ownership into an existing spawned task. Disk
/// classification/permits are not inherited: changing those can introduce a
/// wait behind a disk permit still held by the parent.
pub(crate) async fn with_optional_resource_unit<F: Future>(unit: Option<AdaptivePermit>, future: F) -> F::Output {
pub(crate) async fn with_optional_resource_unit<F: Future>(unit: Option<ResourceIoUnit>, future: F) -> F::Output {
match unit {
Some(unit) => with_background_resource_unit(unit, future).await,
Some(unit) => RESOURCE_IO_UNIT.scope(unit, future).await,
None => future.await,
}
}
@@ -233,7 +367,7 @@ impl DiskIoAdmission {
self.foreground_with_resource(current_resource_unit())
}
fn foreground_with_resource(self: &Arc<Self>, resource_unit: Option<AdaptivePermit>) -> DiskIoPermit {
fn foreground_with_resource(self: &Arc<Self>, resource_unit: Option<ResourceIoUnit>) -> DiskIoPermit {
self.foreground_active.fetch_add(1, Ordering::AcqRel);
if self.background_waiters.load(Ordering::Acquire) > 0 {
self.record_state();
@@ -254,7 +388,7 @@ impl DiskIoAdmission {
async fn background_with_resource(
self: &Arc<Self>,
cancellation: &CancellationToken,
resource_unit: Option<AdaptivePermit>,
resource_unit: Option<ResourceIoUnit>,
) -> std::io::Result<DiskIoPermit> {
let started_at = Instant::now();
let ticket = {
@@ -429,7 +563,7 @@ impl DiskIoPermit {
struct DiskIoPermitLease {
admission: Arc<DiskIoAdmission>,
class: DiskIoClass,
resource_unit: Option<AdaptivePermit>,
resource_unit: Option<ResourceIoUnit>,
}
pub(crate) fn spawn_blocking_with_disk_io_permit<T, F>(
@@ -450,6 +584,18 @@ where
})
}
/// Run native filesystem work with the current resource ownership. Cancelling
/// the async waiter cannot refund a unit while the native operation still runs.
/// This does not acquire capacity or change foreground/background policy.
#[doc(hidden)]
pub fn spawn_blocking_with_resource_ownership<T, F>(operation: F) -> tokio::task::JoinHandle<T>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
spawn_blocking_with_disk_io_permit(None, operation)
}
type AdmissionWait = Pin<Box<dyn Future<Output = io::Result<DiskIoPermit>> + Send>>;
impl Drop for DiskIoPermitLease {
@@ -595,6 +741,63 @@ mod tests {
use std::num::NonZeroU16;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn physical_subset_survives_native_cancellation_without_retaining_unrelated_disks() {
use rustfs_concurrency::adaptive::permit_set::AdaptivePermitSetOwner;
for disk_permit in [false, true] {
let maximum = NonZeroU16::new(1).expect("one physical unit");
let first = AdaptiveAdmission::new(maximum, 1);
let second = AdaptiveAdmission::new(maximum, 1);
let unrelated = AdaptiveAdmission::new(maximum, 1);
let owner = AdaptivePermitSetOwner::new(maximum, vec![first.clone(), second.clone()]).expect("layered disk");
let a = first.try_acquire().expect("first backing device");
let b = second.try_acquire().expect("second backing device");
let healthy = unrelated.try_acquire().expect("another disk in the parent operation");
let subset = owner.capture(&[&a, &b]).expect("capture before storage work");
drop((a, b, owner));
let disk = DiskIoAdmission::new("physical-subset-native");
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let _healthy = healthy;
with_background_resource_set(subset, async {
let permit = if disk_permit {
Some(disk.background(&CancellationToken::new()).await.expect("logical disk permit"))
} else {
None
};
spawn_blocking_with_disk_io_permit(permit, move || {
started_tx.send(()).expect("native I/O started");
release_rx.blocking_recv().expect("release native I/O");
})
.await
.expect("native I/O completed");
})
.await;
});
started_rx.await.expect("native worker retains subset");
task.abort();
assert!(task.await.expect_err("cancel async waiter").is_cancelled());
let active = [first.snapshot().active, second.snapshot().active, unrelated.snapshot().active];
let healthy_available = unrelated.try_acquire().is_some();
first.set_limit(0);
second.set_limit(0);
release_tx.send(()).expect("finish native work");
tokio::time::timeout(Duration::from_secs(5), async {
while first.snapshot().active != 0 || second.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("both backing devices are returned after native completion");
assert_eq!(active, [1, 1, 0], "only the native I/O's physical subset remains charged");
assert!(healthy_available, "cancellation frees the independent disk immediately");
assert!(first.try_acquire().is_none());
assert!(second.try_acquire().is_none(), "old completions cannot reopen shrunken owners");
}
}
#[tokio::test]
async fn resource_unit_outlives_cancelled_scope_until_native_io_finishes() {
for permit_mode in 0..4 {
@@ -0,0 +1,547 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use bytes::Bytes;
use std::io;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use tokio::time::Instant;
static BACKGROUND_MEMORY: OnceLock<Arc<BackgroundMemoryBudget>> = OnceLock::new();
/// Process-wide payload storage retained by admitted background I/O. The
/// existing resource sampler publishes limits; this owner starts no workers.
#[derive(Debug)]
pub struct BackgroundMemoryBudget {
// This short critical section only changes counters and a deadline. It
// never allocates, calls storage, or acquires another lock while held.
state: Mutex<State>,
}
#[derive(Debug)]
struct State {
limit: u64,
used: u64,
valid_until: Instant,
}
/// A coherent view of the leased cap and retained payload storage.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BackgroundMemorySnapshot {
/// Zero when the latest resource observation has expired.
pub limit: u64,
/// Existing storage remains charged even after the cap shrinks below it.
pub used: u64,
}
impl BackgroundMemoryBudget {
/// Start closed until the existing resource sampler publishes a limit.
pub fn new() -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(State {
limit: 0,
used: 0,
valid_until: Instant::now(),
}),
})
}
/// Install the sole process owner before starting adaptive background work.
/// Returns false if an owner was already installed.
pub fn install(self: &Arc<Self>) -> bool {
BACKGROUND_MEMORY.set(self.clone()).is_ok()
}
/// Return only the installed process memory policy; no node or disk ownership.
/// No policy is installed in the default, non-adaptive startup path.
pub fn current() -> Option<Arc<Self>> {
BACKGROUND_MEMORY.get().cloned()
}
/// Update admission without revoking storage retained by native tasks or
/// readers. Missing observations must publish zero capacity.
pub fn set_sampled_limit(&self, limit: u64, valid_for: Duration) {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.limit = limit;
let now = Instant::now();
state.valid_until = now.checked_add(valid_for).unwrap_or(now);
}
/// Read the cap and retained byte count under the same short lock.
pub fn snapshot(&self) -> BackgroundMemorySnapshot {
let state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
BackgroundMemorySnapshot {
limit: if Instant::now() < state.valid_until { state.limit } else { 0 },
used: state.used,
}
}
pub(crate) fn reserve(self: &Arc<Self>, bytes: usize) -> io::Result<Reservation> {
let bytes = u64::try_from(bytes).map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?;
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let used = state
.used
.checked_add(bytes)
.ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, BackgroundMemoryExhausted))?;
if bytes != 0 && (Instant::now() >= state.valid_until || used > state.limit) {
return Err(io::Error::new(io::ErrorKind::WouldBlock, BackgroundMemoryExhausted));
}
state.used = used;
Ok(Reservation {
owner: self.clone(),
bytes,
})
}
}
#[derive(Debug)]
struct BackgroundMemoryExhausted;
impl std::fmt::Display for BackgroundMemoryExhausted {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("background buffer memory budget exhausted")
}
}
impl std::error::Error for BackgroundMemoryExhausted {}
pub(crate) fn is_memory_pressure(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::OutOfMemory || error.get_ref().is_some_and(|inner| inner.is::<BackgroundMemoryExhausted>())
}
pub(crate) struct Reservation {
owner: Arc<BackgroundMemoryBudget>,
bytes: u64,
}
impl Reservation {
pub(crate) fn belongs_to(&self, budget: &Arc<BackgroundMemoryBudget>) -> bool {
Arc::ptr_eq(&self.owner, budget)
}
pub(crate) fn wrap(bytes: Bytes, reservation: Option<Self>) -> Bytes {
if bytes.is_empty() {
drop(bytes);
drop(reservation);
return Bytes::new();
}
match reservation {
Some(reservation) => Bytes::from_owner(BackgroundBytes {
bytes,
_reservation: reservation,
}),
None => bytes,
}
}
}
struct BackgroundBytes {
// Free the payload before refunding it, including after the last slice.
bytes: Bytes,
_reservation: Reservation,
}
impl AsRef<[u8]> for BackgroundBytes {
fn as_ref(&self) -> &[u8] {
&self.bytes
}
}
impl Drop for Reservation {
fn drop(&mut self) {
self.owner
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.used -= self.bytes;
}
}
/// Reusable initialized payload storage. The reservation is embedded in the
/// existing native job state, with no per-read owner allocation or payload copy.
pub(crate) struct BackgroundBuffer {
// Field order releases the allocation before refunding its reservation.
bytes: Box<[u8]>,
reservation: Option<Reservation>,
budget: Option<Arc<BackgroundMemoryBudget>>,
}
impl BackgroundBuffer {
pub(crate) fn new(budget: Option<Arc<BackgroundMemoryBudget>>) -> Self {
Self {
bytes: Box::default(),
reservation: None,
budget,
}
}
/// Call only after the previous contents have been consumed. Growth charges
/// both allocations until the old buffer is freed, including after shrink.
pub(crate) fn ensure_len(&mut self, length: usize) -> io::Result<()> {
self.grow(length, 0)
}
/// Preserve an initialized prefix when replacing storage. Callers keep
/// `preserve` within both the existing allocation and the requested length.
pub(crate) fn grow(&mut self, length: usize, preserve: usize) -> io::Result<()> {
if self.bytes.len() >= length {
return Ok(());
}
let reservation = self.budget.as_ref().map(|budget| budget.reserve(length)).transpose()?;
let mut bytes = Vec::new();
bytes
.try_reserve_exact(length)
.map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?;
bytes.resize(length, 0);
bytes[..preserve].copy_from_slice(&self.bytes[..preserve]);
self.bytes = bytes.into_boxed_slice();
self.reservation = reservation;
Ok(())
}
/// Metadata is only a size hint: concurrent growth still reads through EOF.
/// Every replacement is charged before copying the retained prefix.
pub(crate) fn read_to_end(mut self, reader: &mut impl io::Read, hint: usize) -> io::Result<Bytes> {
self.ensure_len(hint)?;
let mut filled = 0;
loop {
if filled == self.bytes.len() {
// Probe EOF without forcing a larger allocation for an exact fit.
let mut probe = [0];
match reader.read(&mut probe) {
Ok(0) => break,
Ok(_) => {
let length = self.bytes.len().checked_mul(2).ok_or(io::ErrorKind::OutOfMemory)?.max(32);
self.grow(length, filled)?;
self.bytes[filled] = probe[0];
filled += 1;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
match reader.read(&mut self.bytes[filled..]) {
Ok(0) => break,
Ok(length) => filled += length,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
Ok(self.take_bytes(filled))
}
pub(crate) fn take_bytes(&mut self, length: usize) -> Bytes {
let bytes = std::mem::take(&mut self.bytes);
let reservation = self.reservation.take();
let mut bytes = Bytes::from(bytes.into_vec());
bytes.truncate(length);
Reservation::wrap(bytes, reservation)
}
pub(crate) fn memory_budget(&self) -> Option<Arc<BackgroundMemoryBudget>> {
self.budget.clone()
}
pub(crate) fn as_mut_slice(&mut self) -> &mut [u8] {
&mut self.bytes
}
pub(crate) fn as_slice(&self) -> &[u8] {
&self.bytes
}
}
/// Bounded encoded-frame storage for serializer output and raw read staging.
/// Both paths reserve the actual allocation before initializing its bytes.
pub struct BackgroundEncodeBuffer {
buffer: BackgroundBuffer,
filled: usize,
maximum: usize,
}
impl BackgroundEncodeBuffer {
pub fn new(memory: Arc<BackgroundMemoryBudget>, maximum: usize) -> Self {
Self {
buffer: BackgroundBuffer::new(Some(memory)),
filled: 0,
maximum,
}
}
/// Borrow initialized raw-frame staging; decode only after a successful read.
/// Staging is not encoded output: `into_bytes` remains empty for this path.
pub fn prepare_read(&mut self, length: usize) -> io::Result<&mut [u8]> {
if length > self.maximum {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"background encoded frame exceeds its size limit",
));
}
self.buffer.ensure_len(length)?;
self.filled = 0;
Ok(&mut self.buffer.as_mut_slice()[..length])
}
pub fn into_bytes(mut self) -> Bytes {
self.buffer.take_bytes(self.filled)
}
}
impl io::Write for BackgroundEncodeBuffer {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let required = self.filled.checked_add(bytes.len()).ok_or(io::ErrorKind::OutOfMemory)?;
if required > self.maximum {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"background encoded frame exceeds its size limit",
));
}
if required > self.buffer.as_slice().len() {
let capacity = self
.buffer
.as_slice()
.len()
.saturating_mul(2)
.max(32)
.max(required)
.min(self.maximum);
self.buffer.grow(capacity, self.filled)?;
}
self.buffer.as_mut_slice()[self.filled..required].copy_from_slice(bytes);
self.filled = required;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoded_buffer_growth_retains_both_allocations_and_the_last_slice() {
use std::io::Write;
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(95, Duration::from_secs(30));
let mut buffer = BackgroundEncodeBuffer::new(owner.clone(), 64);
buffer.write_all(&[7; 32]).expect("initial allocation fits");
assert_eq!(owner.snapshot().used, 32);
let error = buffer.write_all(&[8]).expect_err("old 32 plus replacement 64 exceed 95");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert!(is_memory_pressure(&error));
assert_eq!(buffer.filled, 32);
assert_eq!(buffer.buffer.as_slice(), &[7; 32]);
assert_eq!(owner.snapshot().used, 32);
owner.set_sampled_limit(96, Duration::from_secs(30));
buffer.write_all(&[8]).expect("old 32 plus replacement 64 fit 96");
assert_eq!(owner.snapshot().used, 64);
let bytes = buffer.into_bytes();
assert_eq!(&bytes[..32], &[7; 32]);
assert_eq!(bytes[32], 8);
assert_eq!(bytes.len(), 33);
let slice = bytes.slice(32..);
drop(bytes);
assert_eq!(slice.as_ref(), &[8]);
assert_eq!(owner.snapshot().used, 64);
drop(slice);
assert_eq!(owner.snapshot().used, 0);
}
#[test]
fn encoded_buffer_checks_its_limit_before_reserving_storage() {
use std::io::Write;
let owner = BackgroundMemoryBudget::new();
for maximum in [0, 3] {
let mut buffer = BackgroundEncodeBuffer::new(owner.clone(), maximum);
buffer
.write_all(&[])
.expect("empty input needs no storage, including at limit zero");
let error = buffer
.write_all(&[7; 4])
.expect_err("oversized input must fail before memory admission");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(buffer.buffer.as_slice().is_empty());
assert!(buffer.into_bytes().is_empty());
assert_eq!(owner.snapshot().used, 0);
}
}
#[test]
fn returned_bytes_keep_the_allocation_charged_through_slices_and_shrink() {
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(8, Duration::from_secs(30));
let bytes = BackgroundBuffer::new(Some(owner.clone()))
.read_to_end(&mut &b"abcdefgh"[..], 8)
.expect("exact allocation fits without an EOF growth allocation");
assert_eq!(bytes.as_ref(), b"abcdefgh");
assert_eq!(owner.snapshot().used, 8);
let slice = bytes.slice(2..6);
owner.set_sampled_limit(0, Duration::ZERO);
drop(bytes);
assert_eq!(slice.as_ref(), b"cdef");
assert_eq!(owner.snapshot().used, 8, "the final slice still owns the complete allocation");
assert!(owner.reserve(1).is_err());
drop(slice);
assert_eq!(owner.snapshot().used, 0);
}
#[test]
fn whole_file_size_hint_never_truncates_growth_or_exposes_unused_bytes() {
for (hint, retained) in [(0, 32), (4, 32), (8, 8), (16, 16)] {
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(36, Duration::from_secs(30));
let bytes = BackgroundBuffer::new(Some(owner.clone()))
.read_to_end(&mut &b"abcdefgh"[..], hint)
.expect("read through EOF independently of the metadata hint");
assert_eq!(bytes.as_ref(), b"abcdefgh", "hint={hint}");
assert_eq!(owner.snapshot().used, retained, "charge allocated capacity, not visible length");
drop(bytes);
assert_eq!(owner.snapshot().used, 0);
}
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(16, Duration::from_secs(30));
let bytes = BackgroundBuffer::new(Some(owner.clone()))
.read_to_end(&mut std::io::empty(), 16)
.expect("file shrank to empty");
assert!(bytes.is_empty());
assert_eq!(owner.snapshot().used, 0, "empty results free unused storage before refund");
}
#[test]
fn preserving_growth_keeps_existing_payload_on_denial_and_reuse() {
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(11, Duration::from_secs(30));
let mut buffer = BackgroundBuffer::new(Some(owner.clone()));
buffer.ensure_len(4).expect("allocate initial payload");
buffer.as_mut_slice().copy_from_slice(b"data");
let error = buffer
.grow(8, 4)
.expect_err("old and replacement allocations need twelve bytes");
assert!(is_memory_pressure(&error));
assert_eq!(buffer.as_slice(), b"data");
assert_eq!(owner.snapshot().used, 4);
owner.set_sampled_limit(12, Duration::from_secs(30));
buffer.grow(8, 4).expect("preserve the payload after admission");
assert_eq!(buffer.as_slice(), b"data\0\0\0\0");
assert_eq!(owner.snapshot().used, 8);
owner.set_sampled_limit(0, Duration::ZERO);
buffer.grow(8, 4).expect("reuse retained storage after expiry");
assert_eq!(buffer.as_slice(), b"data\0\0\0\0");
let bytes = buffer.take_bytes(4);
drop(buffer);
assert_eq!(bytes.as_ref(), b"data");
assert_eq!(owner.snapshot().used, 8);
drop(bytes);
assert_eq!(owner.snapshot().used, 0);
}
#[test]
fn whole_file_growth_denial_and_source_failure_refund_storage() {
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(35, Duration::from_secs(30));
let error = BackgroundBuffer::new(Some(owner.clone()))
.read_to_end(&mut &b"abcdefgh"[..], 4)
.expect_err("old four plus new thirty-two bytes exceed capacity");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert_eq!(owner.snapshot().used, 0);
struct FailedRead(u8);
impl io::Read for FailedRead {
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
self.0 += 1;
match self.0 {
1 => Err(io::ErrorKind::Interrupted.into()),
2 => {
bytes[..2].copy_from_slice(b"ab");
Ok(2)
}
_ => Err(io::ErrorKind::PermissionDenied.into()),
}
}
}
let error = BackgroundBuffer::new(Some(owner.clone()))
.read_to_end(&mut FailedRead(0), 8)
.expect_err("failure after partial output is not successful EOF");
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(owner.snapshot().used, 0);
}
#[tokio::test(start_paused = true)]
async fn shrink_and_expiry_keep_retained_storage_charged() {
let owner = BackgroundMemoryBudget::new();
assert!(owner.reserve(1).is_err());
owner.set_sampled_limit(16, Duration::from_secs(3));
let mut buffer = BackgroundBuffer::new(Some(owner.clone()));
buffer.ensure_len(8).expect("initial allocation");
let pointer = buffer.as_slice().as_ptr();
owner.set_sampled_limit(4, Duration::from_secs(3));
buffer.ensure_len(8).expect("existing storage can be reused after shrink");
assert_eq!(buffer.as_slice().as_ptr(), pointer);
assert_eq!(owner.snapshot(), BackgroundMemorySnapshot { limit: 4, used: 8 });
assert_eq!(buffer.ensure_len(9).expect_err("growth must be denied").kind(), io::ErrorKind::WouldBlock);
assert_eq!(owner.snapshot().used, 8, "failed growth preserves the existing reservation");
tokio::time::advance(Duration::from_secs(3)).await;
assert_eq!(owner.snapshot(), BackgroundMemorySnapshot { limit: 0, used: 8 });
assert!(owner.reserve(1).is_err(), "an expired sampler cannot grant memory");
drop(buffer);
assert_eq!(owner.snapshot().used, 0);
}
#[test]
fn growth_accounts_for_both_allocations_and_refunds_failed_attempts() {
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(12, Duration::from_secs(3));
let mut buffer = BackgroundBuffer::new(Some(owner.clone()));
buffer.ensure_len(4).expect("first allocation");
buffer.as_mut_slice().copy_from_slice(b"data");
assert!(buffer.ensure_len(9).is_err(), "four old plus nine new bytes exceed the cap");
assert_eq!(buffer.as_slice(), b"data");
buffer.ensure_len(8).expect("four old plus eight new bytes fit");
assert_eq!(owner.snapshot().used, 8);
assert_eq!(buffer.as_slice(), &[0; 8]);
drop(buffer);
assert_eq!(owner.snapshot().used, 0);
}
#[test]
fn concurrent_reservations_share_one_cap() {
let owner = BackgroundMemoryBudget::new();
owner.set_sampled_limit(1024, Duration::from_secs(30));
let ready = Arc::new(std::sync::Barrier::new(17));
let release = Arc::new(std::sync::Barrier::new(17));
let workers: Vec<_> = (0..16)
.map(|_| {
let owner = owner.clone();
let ready = ready.clone();
let release = release.clone();
std::thread::spawn(move || {
let reservation = owner.reserve(128).ok();
ready.wait();
release.wait();
reservation.is_some()
})
})
.collect();
ready.wait();
let retained = owner.snapshot().used;
release.wait();
let granted = workers
.into_iter()
.map(|worker| worker.join().expect("reservation worker"))
.filter(|granted| *granted)
.count();
assert_eq!(retained, 1024);
assert_eq!(granted, 8);
assert_eq!(owner.snapshot().used, 0);
}
}
@@ -0,0 +1,443 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const MAX_TOPOLOGY_NODES: usize = 256;
const MAX_TOPOLOGY_DEPTH: usize = 32;
const MAX_ATTRIBUTE_BYTES: u64 = 128;
/// Kernel device numbers can be reused after removal. `diskseq` distinguishes
/// those incarnations; this identity does not authorize storage mutations.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct PhysicalDeviceIdentity {
pub(super) major: u32,
pub(super) minor: u32,
pub(super) generation: u64,
}
fn invalid(message: &'static str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
pub(super) fn attribute(path: &Path) -> io::Result<String> {
let mut value = String::new();
File::open(path)?.take(MAX_ATTRIBUTE_BYTES + 1).read_to_string(&mut value)?;
if value.len() > MAX_ATTRIBUTE_BYTES as usize {
return Err(invalid("physical device attribute exceeds its bound"));
}
Ok(value.trim().to_owned())
}
fn number(value: &str) -> io::Result<u64> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("invalid physical device number"));
}
value.parse().map_err(|_| invalid("physical device number overflow"))
}
fn identity(path: &Path) -> io::Result<PhysicalDeviceIdentity> {
let generation = number(&attribute(&path.join("diskseq"))?)?;
if generation == 0 {
return Err(invalid("physical device generation is missing"));
}
let device = attribute(&path.join("dev"))?;
let (major, minor) = device
.split_once(':')
.ok_or_else(|| invalid("invalid physical device identity"))?;
let major = u32::try_from(number(major)?).map_err(|_| invalid("physical major number overflow"))?;
let minor = u32::try_from(number(minor)?).map_err(|_| invalid("physical minor number overflow"))?;
if number(&attribute(&path.join("diskseq"))?)? != generation {
return Err(invalid("physical device changed during discovery"));
}
Ok(PhysicalDeviceIdentity {
major,
minor,
generation,
})
}
#[derive(Debug, Eq, PartialEq)]
enum NodeTopology {
Partition {
device: String,
number: u64,
parent: PathBuf,
},
Whole {
identity: PhysicalDeviceIdentity,
children: Vec<PathBuf>,
},
}
fn canonical_beneath(path: &Path, root: &Path) -> io::Result<PathBuf> {
let path = path.canonicalize()?;
if !path.starts_with(root) {
return Err(invalid("physical device topology escapes sysfs"));
}
Ok(path)
}
fn node_topology(path: &Path, root: &Path) -> io::Result<NodeTopology> {
if canonical_beneath(path, root)? != path {
return Err(invalid("physical device path changed during discovery"));
}
match attribute(&path.join("partition")) {
Ok(partition) => {
let number = number(&partition)?;
if number == 0 {
return Err(invalid("physical partition number is missing"));
}
let parent = path.parent().ok_or_else(|| invalid("physical partition has no parent"))?;
return Ok(NodeTopology::Partition {
device: attribute(&path.join("dev"))?,
number,
parent: canonical_beneath(parent, root)?,
});
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let identity = identity(path)?;
let mut children = Vec::new();
for entry in std::fs::read_dir(path.join("slaves"))? {
if children.len() >= MAX_TOPOLOGY_NODES {
return Err(invalid("physical device topology fanout exceeds its bound"));
}
children.push(canonical_beneath(&entry?.path(), root)?);
}
children.sort_unstable();
children.dedup();
Ok(NodeTopology::Whole { identity, children })
}
struct TopologySnapshot {
root: PathBuf,
entry: PathBuf,
first: PathBuf,
nodes: BTreeMap<PathBuf, NodeTopology>,
leaves: BTreeSet<PhysicalDeviceIdentity>,
}
impl TopologySnapshot {
fn discover(sysfs_root: &Path, major: u64, minor: u64) -> io::Result<Self> {
let root = sysfs_root.canonicalize()?;
let entry = root.join(format!("dev/block/{major}:{minor}"));
let first = canonical_beneath(&entry, &root)?;
if attribute(&first.join("dev"))? != format!("{major}:{minor}") {
return Err(invalid("physical device entry does not match its device number"));
}
let mut stack = vec![(first.clone(), 0, false)];
let mut active = BTreeSet::new();
let mut nodes = BTreeMap::new();
let mut leaves = BTreeMap::<PhysicalDeviceIdentity, PathBuf>::new();
while let Some((path, depth, exiting)) = stack.pop() {
if exiting {
active.remove(&path);
continue;
}
if depth > MAX_TOPOLOGY_DEPTH {
return Err(invalid("physical device topology is too deep"));
}
if active.contains(&path) {
return Err(invalid("physical device topology contains a cycle"));
}
if nodes.contains_key(&path) {
continue;
}
if nodes.len() >= MAX_TOPOLOGY_NODES {
return Err(invalid("physical device topology exceeds its node bound"));
}
let topology = node_topology(&path, &root)?;
active.insert(path.clone());
stack.push((path.clone(), depth, true));
match &topology {
NodeTopology::Partition { parent, .. } => stack.push((parent.clone(), depth + 1, false)),
NodeTopology::Whole { identity, children } => {
if stack.len() + children.len() > MAX_TOPOLOGY_NODES * 2 {
return Err(invalid("physical device topology fanout exceeds its bound"));
}
for child in children {
stack.push((child.clone(), depth + 1, false));
}
if children.is_empty()
&& let Some(previous) = leaves.insert(*identity, path.clone())
&& previous != path
{
return Err(invalid("physical device identity is ambiguous"));
}
}
}
nodes.insert(path, topology);
}
if leaves.is_empty() {
return Err(invalid("physical device topology has no leaves"));
}
Ok(Self {
root,
entry,
first,
nodes,
leaves: leaves.into_keys().collect(),
})
}
fn validate(&self) -> io::Result<()> {
if canonical_beneath(&self.entry, &self.root)? != self.first {
return Err(invalid("physical device entry changed during discovery"));
}
for (path, expected) in &self.nodes {
if &node_topology(path, &self.root)? != expected {
return Err(invalid("physical device topology changed during discovery"));
}
}
Ok(())
}
}
/// Resolve a bounded sysfs topology once, before installing an admission owner.
/// Missing topology or generation evidence is unknown, never a name-based owner.
/// Validate intermediate mappings too: stable leaves alone do not prove that a
/// device-mapper table still points to those leaves. This is an observation, not
/// a kernel topology lock; the admission owner must expire and refresh it.
pub(super) fn resolve_physical_devices(sysfs_root: &Path, major: u64, minor: u64) -> io::Result<Vec<PhysicalDeviceIdentity>> {
let snapshot = TopologySnapshot::discover(sysfs_root, major, minor)?;
snapshot.validate()?;
Ok(snapshot.leaves.into_iter().collect())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
use std::sync::atomic::{AtomicU64, Ordering};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"physical-identity-{}-{stamp}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&root).expect("exclusive test root");
std::fs::create_dir_all(root.join("sys/dev/block")).expect("sysfs fixture");
Self(root)
}
fn root(&self) -> PathBuf {
self.0.join("sys")
}
fn device(&self, name: &str, major: u32, minor: u32, generation: u64) -> PathBuf {
let path = self.root().join("devices").join(name);
std::fs::create_dir_all(path.join("slaves")).expect("device fixture");
std::fs::write(path.join("dev"), format!("{major}:{minor}\n")).expect("device number");
std::fs::write(path.join("diskseq"), format!("{generation}\n")).expect("device generation");
symlink(&path, self.root().join(format!("dev/block/{major}:{minor}"))).expect("sysfs link");
path
}
fn link(&self, parent: &Path, child: &Path, name: &str) {
symlink(child, parent.join("slaves").join(name)).expect("slave link");
}
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).expect("remove owned fixture");
}
}
#[test]
fn partitions_share_the_leaf_generation_and_replacement_changes_identity() {
let fixture = Fixture::new();
let leaf = fixture.device("sda", 8, 0, 41);
let first = fixture.device("sda/sda1", 8, 1, 41);
let second = fixture.device("sda/sda2", 8, 2, 41);
std::fs::write(first.join("partition"), "1\n").expect("first partition");
std::fs::write(second.join("partition"), "2\n").expect("second partition");
let old = resolve_physical_devices(&fixture.root(), 8, 1).expect("first leaf");
assert_eq!(old, resolve_physical_devices(&fixture.root(), 8, 2).expect("alias leaf"));
assert_eq!(
old,
vec![PhysicalDeviceIdentity {
major: 8,
minor: 0,
generation: 41
}]
);
std::fs::write(leaf.join("diskseq"), "42\n").expect("replacement generation");
let new = resolve_physical_devices(&fixture.root(), 8, 1).expect("replacement leaf");
assert_ne!(old, new);
assert_eq!(new[0].generation, 42);
}
#[test]
fn overlapping_stacks_deduplicate_shared_physical_leaves() {
let fixture = Fixture::new();
let a = fixture.device("a", 8, 0, 1);
let b = fixture.device("b", 8, 16, 2);
let inner = fixture.device("dm-0", 253, 0, 3);
let outer = fixture.device("dm-1", 253, 1, 4);
fixture.link(&inner, &a, "a");
fixture.link(&inner, &b, "b");
fixture.link(&outer, &inner, "dm-0");
fixture.link(&outer, &a, "a-again");
assert_eq!(
resolve_physical_devices(&fixture.root(), 253, 0).expect("inner"),
resolve_physical_devices(&fixture.root(), 253, 1).expect("outer")
);
assert_eq!(resolve_physical_devices(&fixture.root(), 253, 1).expect("leaves").len(), 2);
}
#[test]
fn missing_or_invalid_generation_never_falls_back_to_a_device_name() {
let fixture = Fixture::new();
let leaf = fixture.device("a", 8, 0, 1);
for value in ["0", "-1", "18446744073709551616", "+1"] {
std::fs::write(leaf.join("diskseq"), value).expect("bad generation");
assert!(resolve_physical_devices(&fixture.root(), 8, 0).is_err(), "accepted {value}");
}
std::fs::remove_file(leaf.join("diskseq")).expect("missing generation");
assert_eq!(
resolve_physical_devices(&fixture.root(), 8, 0)
.expect_err("unknown generation")
.kind(),
io::ErrorKind::NotFound
);
assert!(resolve_physical_devices(&fixture.root(), 99, 99).is_err());
}
#[test]
fn topology_cycles_and_escaping_links_are_rejected() {
let fixture = Fixture::new();
let a = fixture.device("a", 8, 0, 1);
let b = fixture.device("b", 8, 16, 2);
fixture.link(&a, &b, "b");
fixture.link(&b, &a, "a");
assert!(
resolve_physical_devices(&fixture.root(), 8, 0)
.expect_err("cycle")
.to_string()
.contains("cycle")
);
std::fs::remove_file(b.join("slaves/a")).expect("remove cycle edge");
fixture.link(&b, &fixture.0, "outside");
assert!(
resolve_physical_devices(&fixture.root(), 8, 0)
.expect_err("escape")
.to_string()
.contains("escapes")
);
}
#[test]
fn intermediate_mapping_changes_are_rejected_even_when_old_leaves_survive() {
let fixture = Fixture::new();
let a = fixture.device("a", 8, 0, 1);
let b = fixture.device("b", 8, 16, 2);
let mapper = fixture.device("dm-0", 253, 0, 3);
fixture.link(&mapper, &a, "backing");
let snapshot = TopologySnapshot::discover(&fixture.root(), 253, 0).expect("initial mapping");
snapshot.validate().expect("unchanged topology");
std::fs::remove_file(mapper.join("slaves/backing")).expect("replace mapping");
fixture.link(&mapper, &b, "backing");
assert_eq!(identity(&a).expect("old leaf still exists").generation, 1);
assert!(snapshot.validate().is_err(), "stable old leaves cannot certify a changed mapper");
let current = resolve_physical_devices(&fixture.root(), 253, 0).expect("new complete mapping");
assert_eq!(current, vec![identity(&b).expect("new leaf")]);
let snapshot = TopologySnapshot::discover(&fixture.root(), 253, 0).expect("new mapping");
std::fs::write(mapper.join("diskseq"), "4\n").expect("replace mapper incarnation");
assert!(
snapshot.validate().is_err(),
"an intermediate generation change also invalidates discovery"
);
}
#[test]
fn entry_and_partition_changes_are_not_hidden_by_an_unchanged_parent() {
let fixture = Fixture::new();
fixture.device("a", 8, 0, 1);
let partition = fixture.device("a/p1", 8, 1, 1);
std::fs::write(partition.join("partition"), "1\n").expect("partition index");
let snapshot = TopologySnapshot::discover(&fixture.root(), 8, 1).expect("partition topology");
std::fs::write(partition.join("partition"), "2\n").expect("changed partition");
assert!(snapshot.validate().is_err());
let b = fixture.device("b", 8, 16, 2);
let snapshot = TopologySnapshot::discover(&fixture.root(), 8, 0).expect("first entry");
let entry = fixture.root().join("dev/block/8:0");
std::fs::remove_file(&entry).expect("replace entry");
symlink(&b, &entry).expect("changed entry target");
assert!(snapshot.validate().is_err());
assert!(
resolve_physical_devices(&fixture.root(), 8, 0).is_err(),
"entry device numbers must match"
);
}
#[test]
fn missing_slave_directory_is_unknown_not_a_physical_leaf() {
let fixture = Fixture::new();
let mapper = fixture.device("dm-0", 253, 0, 1);
std::fs::remove_dir(mapper.join("slaves")).expect("missing topology evidence");
assert!(resolve_physical_devices(&fixture.root(), 253, 0).is_err());
}
#[test]
fn topology_depth_and_attribute_sizes_are_bounded() {
let fixture = Fixture::new();
let first = fixture.device("d0", 253, 0, 1);
let mut previous = first.clone();
for index in 1..=MAX_TOPOLOGY_DEPTH + 1 {
let child = fixture.device(&format!("d{index}"), 253, u32::try_from(index).expect("bounded index"), 1);
fixture.link(&previous, &child, "next");
previous = child;
}
assert!(
resolve_physical_devices(&fixture.root(), 253, 0)
.expect_err("depth bound")
.to_string()
.contains("deep")
);
std::fs::remove_file(first.join("slaves/next")).expect("make first node a leaf");
std::fs::write(first.join("diskseq"), "1".repeat(129)).expect("oversized attribute");
assert!(
resolve_physical_devices(&fixture.root(), 253, 0)
.expect_err("attribute bound")
.to_string()
.contains("attribute")
);
}
#[test]
fn exact_node_bound_passes_and_one_more_node_is_rejected() {
let fixture = Fixture::new();
let root = fixture.device("root", 253, 0, 1);
for index in 0..MAX_TOPOLOGY_NODES - 1 {
let leaf = fixture.device(&format!("leaf-{index}"), 8, u32::try_from(index).expect("bounded index"), 2);
fixture.link(&root, &leaf, &format!("leaf-{index}"));
}
assert_eq!(
resolve_physical_devices(&fixture.root(), 253, 0)
.expect("exact node bound")
.len(),
MAX_TOPOLOGY_NODES - 1
);
let extra = fixture.device("extra", 9, 0, 3);
fixture.link(&root, &extra, "extra");
assert!(
resolve_physical_devices(&fixture.root(), 253, 0).is_err(),
"reject a graph above the node bound"
);
}
}
@@ -0,0 +1,268 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::num::NonZeroU16;
use std::sync::{Arc, Weak};
use std::time::Duration;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
#[cfg(test)]
use rustfs_concurrency::adaptive::{AdaptiveAdmissionSnapshot, AdaptivePermit};
use super::physical_identity::PhysicalDeviceIdentity;
const MAX_PHYSICAL_OWNERS: usize = 256;
/// Resolve aliases outside object work. The sampling owner serializes changes;
/// attached handles and native permits never consult this registry.
pub(super) struct PhysicalOwnerRegistry {
maximum: NonZeroU16,
owners: BTreeMap<PhysicalDeviceIdentity, Weak<AdaptiveAdmission>>,
}
/// One attachment to an existing physical capacity owner, not a reservation.
/// The gate itself owns permit slots, so native permit clones keep a registry
/// entry alive even after the last logical disk attachment disappears.
pub(super) struct PhysicalDeviceAdmission {
#[cfg(test)]
identity: PhysicalDeviceIdentity,
gate: Arc<AdaptiveAdmission>,
}
impl PhysicalOwnerRegistry {
pub(super) fn new(maximum: NonZeroU16) -> Self {
Self {
maximum,
owners: BTreeMap::new(),
}
}
/// Old topology descriptors may still reference an unused gate. Close it
/// before reclaiming its registry slot; only admitted I/O keeps an absent
/// incarnation alive, and its final release still reaches the same owner.
pub(super) fn retire_absent(&mut self, current: &BTreeSet<PhysicalDeviceIdentity>) {
self.owners.retain(|identity, owner| {
let Some(gate) = owner.upgrade() else {
return false;
};
if current.contains(identity) {
return true;
}
gate.set_sampled_limit(0, Duration::ZERO);
gate.snapshot().active != 0
});
}
pub(super) fn bind(&mut self, identities: &[PhysicalDeviceIdentity]) -> io::Result<Vec<PhysicalDeviceAdmission>> {
if identities.is_empty() || identities.len() > MAX_PHYSICAL_OWNERS || identities.iter().any(|id| id.generation == 0) {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid physical capacity identities"));
}
let identities = identities.iter().copied().collect::<BTreeSet<_>>();
self.owners.retain(|_, owner| owner.strong_count() != 0);
let new_owners = identities
.iter()
.filter(|identity| !self.owners.contains_key(identity))
.count();
if new_owners > MAX_PHYSICAL_OWNERS - self.owners.len() {
return Err(io::Error::new(io::ErrorKind::WouldBlock, "physical capacity registry is full"));
}
Ok(identities
.into_iter()
.map(|identity| {
let gate = self.owners.get(&identity).and_then(Weak::upgrade).unwrap_or_else(|| {
let gate = AdaptiveAdmission::new(self.maximum, 0);
self.owners.insert(identity, Arc::downgrade(&gate));
gate
});
PhysicalDeviceAdmission {
#[cfg(test)]
identity,
gate,
}
})
.collect())
}
/// Only the sampling owner publishes capacity. New or expired identities
/// remain closed until a fresh observation establishes a bounded limit.
#[cfg(test)]
pub(super) fn publish(&mut self, identity: PhysicalDeviceIdentity, limit: u32, valid_for: Duration) -> bool {
let Some(gate) = self.owners.get(&identity).and_then(Weak::upgrade) else {
return false;
};
gate.set_sampled_limit(limit, valid_for);
true
}
}
impl PhysicalDeviceAdmission {
pub(super) fn gate(&self) -> &Arc<AdaptiveAdmission> {
&self.gate
}
#[cfg(test)]
pub(super) fn identity(&self) -> PhysicalDeviceIdentity {
self.identity
}
#[cfg(test)]
pub(super) fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
self.gate.snapshot()
}
#[cfg(test)]
pub(super) fn try_acquire(&self) -> Option<AdaptivePermit> {
self.gate.try_acquire()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device(minor: u32, generation: u64) -> PhysicalDeviceIdentity {
PhysicalDeviceIdentity {
major: 259,
minor,
generation,
}
}
fn registry() -> PhysicalOwnerRegistry {
PhysicalOwnerRegistry::new(NonZeroU16::new(2).expect("two units"))
}
#[test]
fn aliases_and_overlapping_stacks_share_one_physical_capacity() {
let mut registry = registry();
let a = device(1, 11);
let b = device(2, 12);
let single = registry.bind(&[a]).expect("single device");
let stack = registry.bind(&[b, a, a]).expect("overlapping stack");
assert_eq!(stack.len(), 2, "duplicates cannot reserve additional units");
assert_eq!(stack[0].identity(), a);
assert!(single[0].try_acquire().is_none(), "unobserved capacity is closed");
assert!(registry.publish(a, 1, Duration::from_secs(60)));
let held = single[0].try_acquire().expect("physical unit");
assert!(stack[0].try_acquire().is_none(), "the alias cannot double the capacity");
assert_eq!(stack[0].snapshot().active, 1);
drop(held);
assert!(stack[0].try_acquire().is_some());
}
#[test]
fn native_tail_keeps_the_owner_alive_after_all_attachments_are_gone() {
let mut registry = registry();
let id = device(1, 11);
let attachment = registry.bind(&[id]).expect("logical disk");
assert!(registry.publish(id, 1, Duration::from_secs(60)));
let waiter = attachment[0].try_acquire().expect("admitted work");
let native = waiter.clone();
drop(waiter);
drop(attachment);
let reconnected = registry.bind(&[id]).expect("reconnect during native I/O");
assert_eq!(reconnected[0].snapshot().active, 1);
assert!(reconnected[0].try_acquire().is_none());
drop(native);
assert!(reconnected[0].try_acquire().is_some(), "only the final native release returns capacity");
}
#[test]
fn replacement_generation_does_not_receive_old_native_refunds() {
let mut registry = registry();
let old_id = device(1, 11);
let new_id = device(1, 12);
let old = registry.bind(&[old_id]).expect("old incarnation");
assert!(registry.publish(old_id, 1, Duration::from_secs(60)));
let native = old[0].try_acquire().expect("old native I/O");
drop(old);
let new = registry.bind(&[new_id]).expect("replacement incarnation");
assert_eq!(new[0].snapshot().active, 0);
assert!(new[0].try_acquire().is_none());
assert!(registry.publish(new_id, 1, Duration::from_secs(60)));
let current = new[0].try_acquire().expect("new work");
drop(native);
assert_eq!(new[0].snapshot().active, 1, "old completion cannot refund the replacement");
assert!(new[0].try_acquire().is_none());
drop(current);
assert_eq!(new[0].snapshot().active, 0);
}
#[test]
fn shrink_and_expiry_preserve_active_units_and_leave_other_disks_available() {
let mut registry = registry();
let a = device(1, 11);
let b = device(2, 12);
let disks = registry.bind(&[a, b]).expect("two physical disks");
assert!(registry.publish(a, 2, Duration::from_secs(60)));
assert!(registry.publish(b, 1, Duration::from_secs(60)));
let first = disks[0].try_acquire().expect("first unit");
let second = disks[0].try_acquire().expect("second unit");
assert!(registry.publish(a, 1, Duration::from_secs(60)));
assert_eq!(disks[0].snapshot().active, 2);
assert!(disks[0].try_acquire().is_none());
assert!(disks[1].try_acquire().is_some(), "a stalled disk cannot consume another disk's capacity");
assert!(registry.publish(a, 2, Duration::ZERO));
assert_eq!(disks[0].snapshot().limit, 0);
assert_eq!(disks[0].snapshot().active, 2);
drop((first, second));
assert!(disks[0].try_acquire().is_none(), "expired observations do not reopen after completion");
}
#[test]
fn registry_bound_preserves_live_native_owners_and_reclaims_only_dead_entries() {
let mut registry = registry();
let ids = (0..MAX_PHYSICAL_OWNERS)
.map(|minor| device(u32::try_from(minor).expect("bounded minor"), 1))
.collect::<Vec<_>>();
let attachments = registry.bind(&ids).expect("exact registry bound");
let extra = device(999, 1);
assert!(matches!(registry.bind(&[extra]), Err(error) if error.kind() == io::ErrorKind::WouldBlock));
assert_eq!(registry.owners.len(), MAX_PHYSICAL_OWNERS, "failed binding must not add partial owners");
assert!(registry.publish(ids[0], 1, Duration::from_secs(60)));
let native = attachments[0].try_acquire().expect("native tail");
drop(attachments);
let replacement = registry.bind(&[extra]).expect("dead owners reclaimed");
assert_eq!(registry.owners.len(), 2, "the native tail still owns its original entry");
let original = registry.bind(&[ids[0]]).expect("original native owner");
assert_eq!(original[0].snapshot().active, 1);
drop((native, original, replacement));
assert!(!registry.publish(extra, 1, Duration::from_secs(60)), "a dead owner cannot be refreshed");
assert!(registry.bind(&[]).is_err());
assert!(registry.bind(&[device(1, 0)]).is_err());
}
#[test]
fn retirement_reclaims_idle_descriptors_but_keeps_active_native_incarnations() {
let mut registry = registry();
let ids = (0..MAX_PHYSICAL_OWNERS)
.map(|minor| device(u32::try_from(minor).expect("bounded minor"), 1))
.collect::<Vec<_>>();
let descriptors = registry.bind(&ids).expect("full registry");
assert!(registry.publish(ids[0], 1, Duration::from_secs(60)));
let native = descriptors[0].try_acquire().expect("active old incarnation");
let mut current = ids[2..].iter().copied().collect::<BTreeSet<_>>();
registry.retire_absent(&current);
assert_eq!(registry.owners.len(), MAX_PHYSICAL_OWNERS - 1);
assert_eq!(descriptors[0].snapshot().active, 1);
assert!(descriptors[0].try_acquire().is_none());
assert!(descriptors[1].try_acquire().is_none());
let first = device(0, 2);
let second = device(1, 2);
let replacement = registry.bind(&[first]).expect("idle descriptor was reclaimed");
current.insert(first);
assert!(matches!(registry.bind(&[second]), Err(error) if error.kind() == io::ErrorKind::WouldBlock));
drop(native);
registry.retire_absent(&current);
let next = registry.bind(&[second]).expect("final native release permits reclamation");
assert_eq!(replacement[0].snapshot().active, 0);
assert_eq!(next[0].snapshot().active, 0);
assert_eq!(descriptors[0].snapshot().active, 0);
assert!(
!registry.publish(ids[0], 1, Duration::from_secs(60)),
"retired identity cannot be refreshed"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,309 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
use super::{PhysicalDiskBinding, ResourceIoUnit};
use rustfs_concurrency::adaptive::{
NodeAdmission,
permit_set::{NodeResourceCompletion, NodeResourcePermit},
};
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::io;
use std::sync::Arc;
tokio::task_local! {
static REMOTE_RESOURCES: RemoteBackgroundIo;
}
/// Receiver-owned admission for an authenticated background RPC. The owner must
/// be the same node controller used by local Scanner and Heal work.
pub struct RemoteBackgroundIo {
node: Arc<NodeAdmission>,
admitted: RefCell<Option<NodeResourcePermit>>,
progressed: Cell<bool>,
finished: Cell<bool>,
}
impl RemoteBackgroundIo {
pub fn new(node: Arc<NodeAdmission>) -> Self {
Self {
node,
admitted: RefCell::new(None),
progressed: Cell::new(false),
finished: Cell::new(false),
}
}
/// Move the request's reservations into its existing stream producer.
/// Only physical subsets may be cloned into native I/O; never its node unit.
pub fn take_current() -> Option<Self> {
REMOTE_RESOURCES
.try_with(|context| Self {
node: context.node.clone(),
admitted: RefCell::new(context.admitted.take()),
progressed: Cell::new(context.progressed.replace(false)),
finished: Cell::new(false),
})
.ok()
}
/// Defer a response's progress without keeping async admission alive while
/// its body streams. The reader retains its own native resource ownership.
pub fn take_current_completion() -> Option<NodeResourceCompletion> {
REMOTE_RESOURCES
.try_with(|context| {
let admitted = context.admitted.take();
if context.progressed.replace(false) {
admitted.map(NodeResourcePermit::into_completion)
} else {
None
}
})
.ok()
.flatten()
}
/// A failed response or cancelled producer must not credit earlier phases.
pub fn discard_current_progress() {
let _ = REMOTE_RESOURCES.try_with(|context| context.progressed.set(false));
}
}
impl Drop for RemoteBackgroundIo {
fn drop(&mut self) {
if self.finished.get()
&& self.progressed.get()
&& let Some(permit) = self.admitted.get_mut().take()
{
permit.complete();
}
}
}
/// Scope a verified request or its stream producer. An interrupted request
/// returns node capacity while native jobs retain their selected physical units.
pub async fn with_remote_background_io<F: Future>(context: Option<RemoteBackgroundIo>, operation: F) -> F::Output {
match context {
Some(context) => {
REMOTE_RESOURCES
.scope(context, async {
let result = operation.await;
REMOTE_RESOURCES.with(|context| context.finished.set(true));
result
})
.await
}
None => operation.await,
}
}
pub(crate) fn active() -> bool {
REMOTE_RESOURCES.try_with(|_| ()).is_ok()
}
pub(crate) fn resources() -> Option<ResourceIoUnit> {
REMOTE_RESOURCES
.try_with(|context| {
context
.admitted
.borrow()
.as_ref()
.map(|permit| ResourceIoUnit::from(permit.resources().clone()))
})
.ok()
.flatten()
}
pub(crate) fn record_progress() {
let _ = REMOTE_RESOURCES.try_with(|context| context.progressed.set(true));
}
pub(crate) fn admit(binding: &PhysicalDiskBinding) -> io::Result<()> {
REMOTE_RESOURCES
.try_with(|context| {
let deferred = || io::Error::from(io::ErrorKind::WouldBlock);
let plan = binding.plan().ok_or_else(deferred)?;
let mut admitted = context.admitted.borrow_mut();
if let Some(permit) = admitted.as_ref() {
// Reuse the request's receipt for append/read/rename phases at
// capacity one. Additional attachments never wait under locks.
let native = plan.capture_or_acquire_native(permit.resources()).map_err(|_| deferred())?;
drop(native);
if !plan.is_current() {
return Err(deferred());
}
} else {
*admitted = Some(plan.try_acquire(&context.node).ok_or_else(deferred)?);
}
Ok(())
})
.unwrap_or(Ok(()))
}
#[cfg(test)]
mod tests {
use super::*;
use rustfs_concurrency::adaptive::{
AdaptiveAdmission, FeedbackController, FeedbackObservation, ResourcePressure, permit_set::AdaptivePermitSetOwner,
};
use std::num::NonZeroU16;
fn node() -> Arc<NodeAdmission> {
let node = NodeAdmission::new(NonZeroU16::MIN);
node.update(
&mut FeedbackController::new(),
Some(FeedbackObservation {
generation: 1,
max_concurrency: 1,
pressure: ResourcePressure::Unknown,
completed_units: None,
}),
);
node
}
#[tokio::test]
async fn receiver_reuses_one_receipt_across_phases_and_releases_only_native_subsets() {
let node = node();
let a = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let b = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let shared = AdaptivePermitSetOwner::new(NonZeroU16::MIN, vec![a.clone(), b.clone()]).expect("device catalog");
let first = PhysicalDiskBinding::default();
let second = PhysicalDiskBinding::default();
first
.set_test_shared_capacity(shared.clone(), vec![a.clone()])
.expect("device A");
second.set_test_shared_capacity(shared, vec![b.clone()]).expect("device B");
let native = with_remote_background_io(Some(RemoteBackgroundIo::new(node.clone())), async {
assert!(resources().is_none(), "no physical work before target selection");
admit(&first).expect("first phase");
let admitted = resources().expect("admitted resources");
let native = first
.plan()
.expect("current plan")
.capture_native(admitted.as_set().expect("resource set"))
.expect("escaped native read");
admit(&first).expect("second phase reuses capacity while the first reader lives");
admit(&second).expect("additional selected device");
assert_eq!([node.snapshot().active, a.snapshot().active, b.snapshot().active], [1, 1, 1]);
record_progress();
native
})
.await;
assert_eq!([node.snapshot().active, a.snapshot().active, b.snapshot().active], [0, 1, 0]);
assert_eq!([node.completed(), a.completed(), b.completed()], [Some(1), Some(1), Some(1)]);
drop(native);
assert_eq!(a.snapshot().active, 0);
}
#[tokio::test]
async fn receiver_cancellation_returns_node_but_not_escaped_native_capacity() {
let node = node();
let gate = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let binding = PhysicalDiskBinding::default();
binding.set_test_capacity(vec![gate.clone()]).expect("device plan");
let (sender, receiver) = tokio::sync::oneshot::channel();
let task = tokio::spawn(with_remote_background_io(Some(RemoteBackgroundIo::new(node.clone())), async move {
admit(&binding).expect("receiver admission");
let admitted = resources().expect("receiver resources");
let native = binding
.plan()
.expect("plan")
.capture_native(admitted.as_set().expect("set"))
.expect("native subset");
sender.send(native).expect("native owner receiver");
record_progress();
std::future::pending::<()>().await;
}));
let native = receiver.await.expect("native read ownership");
task.abort();
assert!(task.await.expect_err("cancel request").is_cancelled());
assert_eq!([node.snapshot().active, gate.snapshot().active], [0, 1]);
assert_eq!([node.completed(), gate.completed()], [Some(0), Some(0)]);
drop(native);
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn receiver_defers_stream_progress_without_retaining_node_capacity() {
for (progressed, succeeds) in [(false, true), (true, false), (true, true)] {
let node = node();
let gate = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let binding = PhysicalDiskBinding::default();
binding.set_test_capacity(vec![gate.clone()]).expect("device plan");
let completion = with_remote_background_io(Some(RemoteBackgroundIo::new(node.clone())), async {
admit(&binding).expect("receiver admission");
if progressed {
record_progress();
}
let completion = RemoteBackgroundIo::take_current_completion();
assert!(RemoteBackgroundIo::take_current_completion().is_none(), "single progress owner");
assert!(resources().is_none());
completion
})
.await;
assert_eq!([node.snapshot().active, gate.snapshot().active], [0, 0]);
assert_eq!([node.completed(), gate.completed()], [Some(0), Some(0)]);
assert_eq!(completion.is_some(), progressed);
if succeeds && let Some(completion) = completion {
completion.complete();
}
let expected = u64::from(progressed && succeeds);
assert_eq!([node.completed(), gate.completed()], [Some(expected), Some(expected)]);
}
assert!(
RemoteBackgroundIo::take_current_completion().is_none(),
"foreground has no progress owner"
);
}
#[tokio::test]
async fn receiver_failed_completion_does_not_credit_successful_preparation() {
let node = node();
let gate = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let binding = PhysicalDiskBinding::default();
binding.set_test_capacity(vec![gate.clone()]).expect("device plan");
with_remote_background_io(Some(RemoteBackgroundIo::new(node.clone())), async {
admit(&binding).expect("receiver admission");
record_progress();
RemoteBackgroundIo::discard_current_progress();
})
.await;
assert_eq!([node.snapshot().active, gate.snapshot().active], [0, 0]);
assert_eq!([node.completed(), gate.completed()], [Some(0), Some(0)]);
}
#[tokio::test]
async fn receiver_stream_transfer_moves_node_ownership_and_defers_unavailable_devices() {
let node = node();
let gate = AdaptiveAdmission::new(NonZeroU16::MIN, 0);
let binding = PhysicalDiskBinding::default();
binding.set_test_capacity(vec![gate.clone()]).expect("device plan");
let context = with_remote_background_io(Some(RemoteBackgroundIo::new(node.clone())), async {
assert_eq!(admit(&binding).expect_err("closed device").kind(), io::ErrorKind::WouldBlock);
assert_eq!([node.snapshot().active, gate.snapshot().active], [0, 0]);
gate.set_limit(1);
admit(&binding).expect("opened device");
let context = RemoteBackgroundIo::take_current().expect("stream context");
assert!(resources().is_none(), "request cannot keep a second node owner");
context
})
.await;
assert_eq!([node.snapshot().active, gate.snapshot().active], [1, 1]);
assert_eq!(node.completed(), Some(0));
with_remote_background_io(Some(context), async {
admit(&binding).expect("stream reuses the request's receipt");
binding
.set_test_capacity(vec![AdaptiveAdmission::new(NonZeroU16::MIN, 1)])
.expect("replace physical device");
assert_eq!(
admit(&binding).expect_err("old receipt cannot cross topology").kind(),
io::ErrorKind::WouldBlock
);
})
.await;
assert_eq!([node.snapshot().active, gate.snapshot().active], [0, 0]);
assert_eq!(node.completed(), Some(0), "admission alone is not completed work");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,740 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::disk::error::{DiskError, Result};
use std::{fs::File, sync::Arc};
/// A target disk's admitted artifact publication. Physical mutation workers
/// retain this Arc until their final syscall and rollback have finished.
pub struct ArtifactLease {
_active: File,
_key_directory: File,
_seal_directory: File,
_root: File,
}
pub type ArtifactPutLease = ArtifactLease;
pub type ArtifactRepairLease = ArtifactLease;
/// Read-only census owns both already-closed physical mutation domains.
pub(super) struct ArtifactInventoryLease {
_put: Arc<ArtifactLease>,
_repair: Arc<ArtifactLease>,
}
pub(super) async fn drain_closed(root: File, key: [u8; 32], deadline: tokio::time::Instant) -> Result<ArtifactInventoryLease> {
#[cfg(unix)]
{
unix::drain_closed(root, key, deadline).await
}
#[cfg(not(unix))]
{
let _ = (root, key, deadline);
Err(DiskError::UnsupportedDisk)
}
}
#[derive(Clone, Copy)]
pub(crate) enum Domain {
Put,
Repair,
}
impl Domain {
fn directory(self) -> &'static str {
match self {
Self::Put => ".rustfs-artifact-put-seals",
Self::Repair => ".rustfs-artifact-repair-seals",
}
}
fn closed(self) -> &'static [u8] {
match self {
Self::Put => b"rustfs-artifact-put-sealed-v1\n",
Self::Repair => b"rustfs-artifact-repair-sealed-v1\n",
}
}
}
pub(super) async fn pinned_root(disk: &super::LocalDisk) -> Result<File> {
#[cfg(target_os = "linux")]
let root = disk.mount_lease.clone();
#[cfg(all(unix, not(target_os = "linux")))]
let root = disk.artifact_identity_root.clone();
#[cfg(not(unix))]
let _ = disk;
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
root.try_clone().map_err(DiskError::from)
}
#[cfg(not(unix))]
{
Err(DiskError::other("artifact disk leases require Unix"))
}
})
.await
.map_err(DiskError::from)?
}
pub(super) async fn acquire(root: File, key: [u8; 32]) -> Result<Arc<ArtifactPutLease>> {
acquire_in(root, key, Domain::Put).await
}
pub(super) async fn acquire_in(root: File, key: [u8; 32], domain: Domain) -> Result<Arc<ArtifactLease>> {
#[cfg(unix)]
{
unix::acquire(root, key, domain).await
}
#[cfg(not(unix))]
{
let _ = (root, key, domain);
Err(DiskError::other("artifact disk leases require Unix"))
}
}
pub(super) async fn seal(root: File, key: [u8; 32]) -> Result<()> {
seal_in(root, key, Domain::Put).await
}
pub(super) async fn seal_in(root: File, key: [u8; 32], domain: Domain) -> Result<()> {
#[cfg(unix)]
{
unix::seal(root, key, domain).await
}
#[cfg(not(unix))]
{
let _ = (root, key, domain);
Err(DiskError::other("artifact disk leases require Unix"))
}
}
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{FlockOperation, Mode, OFlags, flock, mkdirat, openat};
use rustix::io::Errno;
use std::{io, io::Read, io::Write, os::unix::fs::MetadataExt, time::Duration};
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
struct Opened {
root: File,
seals: File,
key: File,
admission: File,
active: File,
domain: Domain,
}
fn invalid() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "invalid artifact seal filesystem state")
}
fn directory(parent: &File, name: &str, device: u64) -> io::Result<File> {
match mkdirat(parent, name, Mode::RWXU) {
Ok(()) | Err(Errno::EXIST) => {}
Err(error) => return Err(error.into()),
}
let file = File::from(openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let metadata = file.metadata()?;
if !metadata.is_dir() || metadata.dev() != device || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(file)
}
fn regular(file: &File, device: u64) -> io::Result<std::fs::Metadata> {
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.dev() != device || metadata.nlink() != 1 || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(metadata)
}
fn lock_file(parent: &File, name: &str, device: u64) -> io::Result<File> {
let file = File::from(openat(
parent,
name,
OFlags::CREATE | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::RUSR | Mode::WUSR,
)?);
if regular(&file, device)?.len() != 0 {
return Err(invalid());
}
Ok(file)
}
impl Opened {
fn open(root: File, key: [u8; 32], domain: Domain) -> io::Result<Self> {
let metadata = root.metadata()?;
if !metadata.is_dir() {
return Err(invalid());
}
let seals = directory(&root, domain.directory(), metadata.dev())?;
let name = hex_simd::encode_to_string(key, hex_simd::AsciiCase::Lower);
let key = directory(&seals, &name, metadata.dev())?;
// Neither lock inode is ever removed or replaced, including after
// closing. Independent processes must always lock the same inode.
let admission = lock_file(&key, "admission.lock", metadata.dev())?;
let active = lock_file(&key, "active.lock", metadata.dev())?;
Ok(Self {
root,
seals,
key,
admission,
active,
domain,
})
}
fn closed(&self) -> io::Result<Option<File>> {
self.closed_with_flags(OFlags::RDWR)
}
fn closed_with_flags(&self, access: OFlags) -> io::Result<Option<File>> {
let mut file = match openat(
&self.key,
"closed",
access | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
) {
Ok(file) => File::from(file),
Err(Errno::NOENT) => return Ok(None),
Err(error) => return Err(error.into()),
};
let length = u64::try_from(self.domain.closed().len()).map_err(|_| invalid())?;
if regular(&file, self.root.metadata()?.dev())?.len() != length {
return Err(invalid());
}
let mut bytes = vec![0; self.domain.closed().len()];
file.read_exact(&mut bytes)?;
if bytes != self.domain.closed() {
return Err(invalid());
}
Ok(Some(file))
}
fn admit(self) -> io::Result<Arc<ArtifactLease>> {
if self.closed()?.is_some() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"artifact attempt mutation domain is closed",
));
}
// Admission serializes the closed check with taking this shared
// physical owner. A sealer cannot pass between those two steps.
flock(&self.active, FlockOperation::NonBlockingLockShared)?;
flock(&self.admission, FlockOperation::Unlock)?;
Ok(Arc::new(ArtifactLease {
_active: self.active,
_key_directory: self.key,
_seal_directory: self.seals,
_root: self.root,
}))
}
fn close(self) -> io::Result<Self> {
let marker = match self.closed()? {
Some(marker) => marker,
None => {
let mut marker = File::from(openat(
&self.key,
"closed",
OFlags::CREATE | OFlags::EXCL | OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::RUSR | Mode::WUSR,
)?);
regular(&marker, self.root.metadata()?.dev())?;
marker.write_all(self.domain.closed())?;
marker
}
};
// No admission is released until the permanent denial and every
// ancestor entry are durable. A failed/cancelled close never removes it.
marker.sync_all()?;
self.admission.sync_all()?;
self.active.sync_all()?;
self.key.sync_all()?;
self.seals.sync_all()?;
self.root.sync_all()?;
flock(&self.admission, FlockOperation::Unlock)?;
Ok(self)
}
}
async fn lock(file: &File, operation: FlockOperation) -> io::Result<()> {
tokio::time::timeout(LOCK_TIMEOUT, async {
loop {
match flock(file, operation) {
Ok(()) => return Ok(()),
Err(Errno::WOULDBLOCK | Errno::INTR) => tokio::time::sleep(POLL_INTERVAL).await,
Err(error) => return Err(error.into()),
}
}
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "artifact disk lease lock deadline"))?
}
async fn open(root: File, key: [u8; 32], domain: Domain) -> Result<Opened> {
tokio::task::spawn_blocking(move || Opened::open(root, key, domain))
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)
}
fn read_error(error: io::Error) -> DiskError {
match error.kind() {
io::ErrorKind::NotFound => DiskError::other("artifact inventory requires closed PUT and repair domains"),
io::ErrorKind::InvalidData | io::ErrorKind::NotADirectory => DiskError::FileCorrupt,
io::ErrorKind::TimedOut => DiskError::Timeout,
_ if error.raw_os_error() == Some(Errno::LOOP.raw_os_error()) => DiskError::FileCorrupt,
_ => DiskError::from(error),
}
}
fn existing_directory(parent: &File, name: &str, device: u64) -> io::Result<File> {
let file = File::from(openat(
parent,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?);
let metadata = file.metadata()?;
if metadata.dev() != device || metadata.mode() & 0o077 != 0 {
return Err(invalid());
}
Ok(file)
}
fn existing(root: File, key: [u8; 32], domain: Domain) -> io::Result<Opened> {
let device = root.metadata()?.dev();
let seals = existing_directory(&root, domain.directory(), device)?;
let name = hex_simd::encode_to_string(key, hex_simd::AsciiCase::Lower);
let key = existing_directory(&seals, &name, device)?;
let lock = |name| -> io::Result<File> {
let file = File::from(openat(
&key,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
)?);
if regular(&file, device)?.len() != 0 {
return Err(invalid());
}
Ok(file)
};
let admission = lock("admission.lock")?;
let active = lock("active.lock")?;
let opened = Opened {
root,
seals,
key,
admission,
active,
domain,
};
if opened.closed_with_flags(OFlags::RDONLY)?.is_none() {
return Err(io::Error::from(io::ErrorKind::NotFound));
}
Ok(opened)
}
fn retained(opened: Opened) -> Arc<ArtifactLease> {
Arc::new(ArtifactLease {
_active: opened.active,
_key_directory: opened.key,
_seal_directory: opened.seals,
_root: opened.root,
})
}
pub(super) async fn drain_closed(
root: File,
key: [u8; 32],
deadline: tokio::time::Instant,
) -> Result<ArtifactInventoryLease> {
let (put, repair) = tokio::task::spawn_blocking(move || {
let put = existing(root.try_clone()?, key, Domain::Put)?;
let repair = existing(root, key, Domain::Repair)?;
Ok::<_, io::Error>((put, repair))
})
.await
.map_err(DiskError::from)?
.map_err(read_error)?;
// Lock order: closed PUT active exclusive -> closed repair active
// exclusive. No admission lock is acquired and no seal is created.
tokio::time::timeout_at(deadline, async {
lock(&put.active, FlockOperation::NonBlockingLockExclusive).await?;
lock(&repair.active, FlockOperation::NonBlockingLockExclusive).await
})
.await
.map_err(|_| DiskError::Timeout)?
.map_err(read_error)?;
Ok(ArtifactInventoryLease {
_put: retained(put),
_repair: retained(repair),
})
}
pub(super) async fn acquire(root: File, key: [u8; 32], domain: Domain) -> Result<Arc<ArtifactLease>> {
let opened = open(root, key, domain).await?;
// Lock order: admission exclusive -> active shared. Contention sleeps
// asynchronously; no blocking executor thread waits for another owner.
lock(&opened.admission, FlockOperation::NonBlockingLockExclusive).await?;
tokio::task::spawn_blocking(move || opened.admit())
.await
.map_err(DiskError::from)?
.map_err(DiskError::from)
}
pub(super) async fn seal(root: File, key: [u8; 32], domain: Domain) -> Result<()> {
let opened = open(root, key, domain).await?;
lock(&opened.admission, FlockOperation::NonBlockingLockExclusive).await?;
let opened = tokio::task::spawn_blocking(move || opened.close())
.await
.map_err(DiskError::from)??;
// Closing is already durable. Cancellation or timeout here only loses
// the confirmation; admission stays permanently closed across reopen.
lock(&opened.active, FlockOperation::NonBlockingLockExclusive).await?;
Ok(())
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::{path::Path, time::Duration};
const KEY: [u8; 32] = [7; 32];
fn root(path: &Path) -> File {
use rustix::fs::{Mode, OFlags, open};
File::from(
open(
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.expect("open pinned test root"),
)
}
fn directory(path: &Path) -> std::path::PathBuf {
path.join(Domain::Put.directory())
.join(hex_simd::encode_to_string(KEY, hex_simd::AsciiCase::Lower))
}
async fn closed(path: &Path) {
tokio::time::timeout(Duration::from_secs(5), async {
while !directory(path).join("closed").exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("sealer reaches its monotonic close point");
}
#[tokio::test]
async fn artifact_repair_closing_domain_is_independent_and_survives_reopen() {
let temp = tempfile::tempdir().expect("disk root");
seal(root(temp.path()), KEY).await.expect("PUT closes first");
let repair = acquire_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("PUT closure permits existing-version repair");
assert!(acquire(root(temp.path()), KEY).await.is_err());
drop(repair);
seal_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("repair closes independently");
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
seal_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("reopened repair close is durable");
let other = [8; 32];
seal_in(root(temp.path()), other, Domain::Repair)
.await
.expect("close only repair admission");
drop(
acquire(root(temp.path()), other)
.await
.expect("repair closure permits PUT domain"),
);
}
#[tokio::test]
async fn artifact_repair_cancelled_waiter_keeps_physical_owner_and_durable_close() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire_in(root(temp.path()), KEY, Domain::Repair)
.await
.expect("repair admitted");
let (entered, started) = tokio::sync::oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
let owner = tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let _owner = writer;
let _ = entered.send(());
let _ = waiting.recv();
})
.await
.expect("repair worker");
});
started.await.expect("physical repair entered");
owner.abort();
assert!(owner.await.expect_err("cancel repair waiter").is_cancelled());
let sealer = tokio::spawn(seal_in(root(temp.path()), KEY, Domain::Repair));
let marker = temp
.path()
.join(Domain::Repair.directory())
.join(hex_simd::encode_to_string(KEY, hex_simd::AsciiCase::Lower))
.join("closed");
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.expect("repair admission durably closes");
assert!(!sealer.is_finished(), "ARTIFACT_REPAIR_CANCELLED_PHYSICAL_TAIL");
sealer.abort();
assert!(sealer.await.expect_err("cancel repair close waiter").is_cancelled());
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
let pending = seal_in(root(temp.path()), KEY, Domain::Repair);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_REPAIR_REOPEN_MUST_DRAIN_PHYSICAL_TAIL"
);
release.send(()).expect("release physical repair");
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical drain bounded")
.expect("reopened close confirms repair drain");
assert!(acquire_in(root(temp.path()), KEY, Domain::Repair).await.is_err());
}
#[tokio::test]
async fn artifact_put_seal_waits_for_independent_openers_and_last_clone() {
let temp = tempfile::tempdir().expect("disk root");
let first = acquire(root(temp.path()), KEY).await.expect("first admitted writer");
let tail = first.clone();
let second = acquire(root(temp.path()), KEY).await.expect("independent shared writer");
let pending = seal(root(temp.path()), KEY);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_ACTIVE_WRITERS_DRAIN"
);
drop(first);
drop(second);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_PHYSICAL_CLONE_RETAINED"
);
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CLOSE_REJECTS_ADMISSION");
drop(tail);
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical tail drain bounded")
.expect("seal complete");
seal(root(temp.path()), KEY)
.await
.expect("independent reopen preserves closed state");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CLOSED_AFTER_REOPEN");
}
#[tokio::test]
async fn artifact_put_close_cancellation_leaves_durable_denial() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("admitted writer");
let sealer = tokio::spawn(seal(root(temp.path()), KEY));
closed(temp.path()).await;
sealer.abort();
assert!(sealer.await.expect_err("cancel close waiter").is_cancelled());
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_CANCELLED_CLOSE_STAYS_CLOSED"
);
drop(writer);
seal(root(temp.path()), KEY).await.expect("reopen finishes physical drain");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CANCELLED_CLOSE_REOPEN");
}
#[tokio::test]
async fn artifact_put_physical_worker_retains_lease_after_waiter_cancellation() {
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("physical writer admitted");
let (entered, started) = tokio::sync::oneshot::channel();
let (release, waiting) = std::sync::mpsc::channel();
let owner = tokio::spawn(async move {
tokio::task::spawn_blocking(move || {
let _physical_owner = writer;
let _ = entered.send(());
let _ = waiting.recv();
})
.await
.expect("physical worker");
});
started.await.expect("physical syscall entered");
owner.abort();
assert!(owner.await.expect_err("cancel async writer").is_cancelled());
let pending = seal(root(temp.path()), KEY);
tokio::pin!(pending);
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut pending).await.is_err(),
"ARTIFACT_PUT_CANCELLED_PHYSICAL_TAIL"
);
release.send(()).expect("release physical worker");
tokio::time::timeout(Duration::from_secs(5), &mut pending)
.await
.expect("physical worker drains")
.expect("seal confirms real completion");
}
#[tokio::test]
async fn artifact_put_seal_waits_for_another_process() {
const CHILD_ROOT: &str = "RUSTFS_TEST_ARTIFACT_PUT_SEAL_ROOT";
if let Some(path) = std::env::var_os(CHILD_ROOT) {
seal(root(Path::new(&path)), KEY)
.await
.expect("child closes and drains parent writer");
assert!(acquire(root(Path::new(&path)), KEY).await.is_err(), "ARTIFACT_PUT_CHILD_CLOSED");
return;
}
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let temp = tempfile::tempdir().expect("disk root");
let writer = acquire(root(temp.path()), KEY).await.expect("parent owns physical write");
let mut child = Child(
std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"disk::local::artifact_lease::tests::artifact_put_seal_waits_for_another_process",
])
.env(CHILD_ROOT, temp.path())
.spawn()
.expect("start independent sealer"),
);
closed(temp.path()).await;
assert!(child.0.try_wait().expect("probe child").is_none(), "ARTIFACT_PUT_CROSS_PROCESS_DRAIN");
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_CROSS_PROCESS_ADMISSION_CLOSED"
);
drop(writer);
let status = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Some(status) = child.0.try_wait().expect("child wait") {
break status;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("child physically drains");
assert!(status.success(), "ARTIFACT_PUT_CROSS_PROCESS_SEAL");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_PROCESS_REOPEN_CLOSED");
}
#[tokio::test]
async fn artifact_put_lease_rejects_symlinks_at_every_seal_component() {
use std::os::unix::fs::{PermissionsExt, symlink};
for component in ["directory", "key", "admission.lock", "active.lock", "closed"] {
let temp = tempfile::tempdir().expect("disk root");
let outside = tempfile::tempdir().expect("symlink destination");
let sentinel = outside.path().join("untouched");
std::fs::write(&sentinel, b"unchanged").expect("sentinel");
let target = match component {
"directory" => temp.path().join(Domain::Put.directory()),
"key" => {
std::fs::create_dir(temp.path().join(Domain::Put.directory())).expect("seal directory");
std::fs::set_permissions(temp.path().join(Domain::Put.directory()), std::fs::Permissions::from_mode(0o700))
.expect("private seal directory");
directory(temp.path())
}
file => {
drop(acquire(root(temp.path()), KEY).await.expect("initialize seal files"));
let target = directory(temp.path()).join(file);
if file != "closed" {
std::fs::remove_file(&target).expect("replace fixture lock");
}
target
}
};
symlink(
if matches!(component, "directory" | "key") {
outside.path()
} else {
&sentinel
},
target,
)
.expect("symlink fixture");
assert!(
acquire(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_SYMLINK_REJECTED: {component}"
);
assert!(
seal(root(temp.path()), KEY).await.is_err(),
"ARTIFACT_PUT_SYMLINK_SEAL_REJECTED: {component}"
);
assert_eq!(std::fs::read(&sentinel).expect("sentinel intact"), b"unchanged");
}
}
#[tokio::test]
async fn artifact_put_lease_rejects_corrupt_closed_marker() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("disk root");
drop(acquire(root(temp.path()), KEY).await.expect("initialize seal files"));
let marker = directory(temp.path()).join("closed");
std::fs::write(&marker, b"partial").expect("corrupt marker fixture");
std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).expect("marker permissions");
assert!(acquire(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CORRUPT_MARKER_FAILS_CLOSED");
assert!(seal(root(temp.path()), KEY).await.is_err(), "ARTIFACT_PUT_CORRUPT_MARKER_NOT_CONFIRMED");
assert_eq!(std::fs::read(marker).expect("corruption retained"), b"partial");
}
#[tokio::test]
async fn artifact_put_lease_stays_on_pinned_root_after_path_replacement() {
let temp = tempfile::tempdir().expect("disk root parent");
let current = temp.path().join("current");
let original = temp.path().join("original");
std::fs::create_dir(&current).expect("initial disk root");
let pin = root(&current);
drop(
acquire(pin.try_clone().expect("root pin clone"), KEY)
.await
.expect("initialize pinned seal"),
);
std::fs::rename(&current, &original).expect("replace configured pathname");
std::fs::create_dir(&current).expect("replacement disk root");
seal(pin, KEY).await.expect("close original disk through its descriptor");
assert!(directory(&original).join("closed").exists(), "ARTIFACT_PUT_ROOT_PINNED");
assert!(!current.join(Domain::Put.directory()).exists(), "ARTIFACT_PUT_REPLACEMENT_UNTOUCHED");
assert!(acquire(root(&original), KEY).await.is_err());
}
}
+51 -168
View File
@@ -28,7 +28,7 @@ use super::{
should_remove_staged_meta_before_commit, skip_access_checks, tmp_staging_mutation_path,
};
#[cfg(test)]
use super::{run_inline_before_file_sync_admission, run_owned_file_write_before_open, run_rename_data_after_first_publication};
use super::{run_inline_before_file_sync_admission, run_owned_file_before_open, run_rename_data_after_first_publication};
use crate::crash_inject::{self, CrashPoint};
use crate::disk::{
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RenameDataResp, STORAGE_FORMAT_FILE, STORAGE_FORMAT_FILE_BACKUP, SnapshotLeaseToken,
@@ -345,8 +345,6 @@ async fn prepare_rename_commit_and_destination_metadata(
let publication_root = publication_root.clone();
os::run_blocking_namespace_operation(mutation_lease, move || {
if create_source_parent {
#[cfg(test)]
super::volume_mutation_test_hooks::run(super::volume_mutation_test_hooks::Stage::Create, &source_parent);
std::fs::create_dir_all(&source_parent)?;
}
let guard = match os::prepare_rename_commit_guard(&source_parent, &destination_parent, &base_dir, &publication_root) {
@@ -367,15 +365,9 @@ async fn prepare_rename_commit_and_destination_metadata(
};
#[cfg(not(any(windows, target_os = "linux")))]
let result = {
let _ = mutation_lease;
if create_source_parent {
let source_parent = source_parent.to_path_buf();
os::run_blocking_namespace_operation(mutation_lease, move || {
#[cfg(test)]
super::volume_mutation_test_hooks::run(super::volume_mutation_test_hooks::Stage::Create, &source_parent);
std::fs::create_dir_all(source_parent)
})
.await
.map_err(to_file_error)?;
fs::create_dir_all(source_parent).await.map_err(to_file_error)?;
}
let guard =
os::prepare_rename_commit_guard(source_parent, destination_parent, base_dir, publication_root).map_err(|err| {
@@ -406,31 +398,30 @@ pub(in crate::disk) struct LocalRenamePreflightRejection(());
pub(super) struct RenameDataState {
namespace_owner: Option<Arc<dyn Send + Sync>>,
preflight_rejection: Option<LocalRenamePreflightRejection>,
prepared: Option<Arc<os::NamespaceMutationLease>>,
}
pub(in crate::disk) struct PreparedRenameData {
file_info: FileInfo,
mutation_lease: Arc<os::NamespaceMutationLease>,
}
impl LocalDisk {
async fn prepare_rename_data_admission(
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
source: (&str, &str),
destination: (&str, &str),
source_volume: Option<&str>,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<PreparedRenameData> {
let (src_volume, src_path) = source;
let (dst_volume, dst_path) = destination;
dst_volume: &str,
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
crate::hp_guard!("LocalDisk::rename_data");
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
}
let mut fi = fi;
// A non-force DeleteBucket must not remove a directory while a local
// object commit is publishing into it. The peer's empty scan remains
// 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 (dst_volume_dir, destination_object_path) = self.io_get_bucket_and_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) => {
@@ -464,27 +455,16 @@ impl LocalDisk {
.strip_prefix(self.io_root())
.map_err(|_| DiskError::InvalidPath)?,
);
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, namespace_owner).await;
let mutation_lease = if let Some(source_volume) = source_volume {
os::acquire_rename_data_mutation_lease_for_volumes(
&self.root,
source_volume,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await
} else {
os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await
};
let metadata_lease =
os::acquire_metadata_mutation_lease(&root_destination_object_path, state.namespace_owner.take()).await;
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await;
if let Some(claim) = quota_fence_claim {
mutation_lease.attach_external_guard(claim);
}
@@ -492,99 +472,6 @@ impl LocalDisk {
fi.erasure.index = 0;
}
fi.validate_for_metadata_read()?;
Ok(PreparedRenameData {
file_info: fi,
mutation_lease,
})
}
pub(in crate::disk) async fn begin_legacy_rename_data(
&self,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
permit: crate::disk::io_admission::DiskIoPermit,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<PreparedRenameData> {
// Capture the same permit, mount FD and listener-instance owner before
// quota -> metadata -> namespace -> sorted volume READ admission.
#[cfg(target_os = "linux")]
let owner: Arc<dyn Send + Sync> = Arc::new((permit, self.mount_lease.clone(), namespace_owner));
#[cfg(not(target_os = "linux"))]
let owner: Arc<dyn Send + Sync> = Arc::new((permit, namespace_owner));
let prepared = self
.prepare_rename_data_admission(fi.clone(), source, destination, Some(source.0), Some(owner))
.await?;
for volume in [Some(source.0), (source.0 != destination.0).then_some(destination.0)]
.into_iter()
.flatten()
{
if !skip_access_checks(volume) {
let path = self.io_get_bucket_path(volume)?;
let metadata = fs::symlink_metadata(path)
.await
.map_err(crate::disk::error_conv::to_volume_error)?;
if !metadata.is_dir() {
return Err(DiskError::VolumeNotFound);
}
}
}
Ok(prepared)
}
pub(in crate::disk) async fn rename_data_prepared(
&self,
source: (&str, &str),
destination: (&str, &str),
prepared: PreparedRenameData,
) -> Result<RenameDataResp> {
let PreparedRenameData {
file_info,
mutation_lease,
} = prepared;
let mut state = RenameDataState {
prepared: Some(mutation_lease),
..Default::default()
};
self.rename_data_inner(source.0, source.1, file_info, destination.0, destination.1, &mut state)
.await
}
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
pub(super) async fn rename_data_inner(
&self,
src_volume: &str,
src_path: &str,
fi: FileInfo,
dst_volume: &str,
dst_path: &str,
state: &mut RenameDataState,
) -> Result<RenameDataResp> {
crate::hp_guard!("LocalDisk::rename_data");
#[cfg(feature = "e2e-test-hooks")]
if fi.is_healing() {
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
}
let PreparedRenameData {
file_info: fi,
mutation_lease,
} = if let Some(mutation_lease) = state.prepared.take() {
PreparedRenameData {
file_info: fi,
mutation_lease,
}
} else {
self.prepare_rename_data_admission(
fi,
(src_volume, src_path),
(dst_volume, dst_path),
None,
state.namespace_owner.take(),
)
.await?
};
let no_inline = fi.data.is_none() && fi.size > 0;
let fi_healing = fi.is_healing();
// Snapshot the destination part paths before `fi` is consumed below. These
// are the descriptors a reader may hold for the version this call is about
// to replace (backlog#1145); readers build the identical string in
@@ -620,7 +507,6 @@ impl LocalDisk {
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
}
let dst_volume_dir = self.io_get_bucket_path(dst_volume)?;
if !skip_access_checks(dst_volume)
&& let Err(e) = super::super::fs::access_std(&dst_volume_dir)
{
@@ -713,10 +599,10 @@ impl LocalDisk {
if preserve_upload_metadata {
let source_object_path = self.get_object_path(src_volume, src_path)?;
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == self.get_object_path(dst_volume, dst_path)? {
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == root_destination_object_path {
commit.mutation_lease.clone()
} else {
os::try_acquire_metadata_mutation_lease_with_owner(&source_object_path, commit.mutation_lease.clone())?
os::acquire_metadata_mutation_lease(&source_object_path, Some(commit.mutation_lease.clone())).await
};
let authority = self.io_get_object_path(src_volume, &format!("{src_path}/{STORAGE_FORMAT_FILE}"))?;
let recovery = self.io_get_object_path(
@@ -724,10 +610,14 @@ impl LocalDisk {
&format!("{src_path}/{}/{STORAGE_FORMAT_FILE}", super::super::MULTIPART_RECOVERY_DIR),
)?;
check_path_length(recovery.to_string_lossy().as_ref())?;
// Both volume READs are already held. Neither source key may wait
// behind a competing owner that is queued behind a volume WRITE.
let recovery_lease = os::try_acquire_namespace_mutation_lease_with_owner(&recovery, authority_owner.clone())?;
os::rename_all_with_lease(&authority, &recovery, &src_volume_dir, commit.publication_root, recovery_lease).await?;
os::rename_all_with_owner(
&authority,
&recovery,
&src_volume_dir,
commit.publication_root,
Some(authority_owner.clone()),
)
.await?;
// This is system metadata authority, so use its existing strict
// durability policy even when the object payload is relaxed.
if effective_durability(src_volume).syncs_commit_metadata() {
@@ -816,7 +706,7 @@ impl LocalDisk {
async move {
os::run_blocking_namespace_operation(owned_mutation_lease, move || {
#[cfg(test)]
run_owned_file_write_before_open(&src_file_path);
run_owned_file_before_open(&src_file_path);
let mut prepared_metadata_source = os::create_prepared_rename_source_with_commit_guard(
&src_file_path,
&dst_file_path,
@@ -948,7 +838,7 @@ impl LocalDisk {
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
let backup_parent = dst_file_parent.join(old_data_dir.to_string());
#[cfg(not(windows))]
if let Err(err) = os::make_dir_all_with_owner(&backup_parent, &skip_parent, commit.mutation_lease.clone()).await {
if let Err(err) = os::make_dir_all(&backup_parent, &skip_parent).await {
commit.restore_published_data_source().await?;
return Err(err);
}
@@ -977,7 +867,7 @@ impl LocalDisk {
// deterministic rollback backup.
let write_result = os::run_blocking_namespace_operation(commit.mutation_lease.clone(), move || {
#[cfg(test)]
run_owned_file_write_before_open(&backup_path);
run_owned_file_before_open(&backup_path);
backup_path_guard.write_file_for_path_access(
&backup_path,
backup_bytes.as_ref(),
@@ -1415,25 +1305,18 @@ impl LocalDisk {
let backup_path = dst_parent
.join(rollback_data_dir.to_string())
.join(STORAGE_FORMAT_FILE_BACKUP);
// Keep backup mutations serialized, but never wait for a second
// namespace while holding the volume READ: its owner can be
// queued behind a volume DELETE that is waiting for this commit.
// rename_all acquires the backup path's namespace lease. Do not
// hold a disk admission while acquiring another namespace lock.
drop(file_sync_admission.take());
let backup_result =
match os::try_acquire_namespace_mutation_lease_with_owner(&backup_path, commit.mutation_lease.clone()) {
Ok(lease) => {
os::rename_all_with_lease(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
lease,
)
.await
}
Err(err) => Err(err),
};
if let Err(err) = backup_result {
if let Err(err) = os::rename_all_with_owner(
staged_backup,
&backup_path,
&dst_volume_dir,
commit.publication_root,
Some(commit.mutation_lease.clone()),
)
.await
{
let _ = remove_file_if_exists(staged_backup);
return Err(err);
}
File diff suppressed because it is too large Load Diff
+52 -156
View File
@@ -27,16 +27,21 @@ mod io_admission;
#[cfg(test)]
pub(crate) mod lifecycle_test_io;
pub mod local;
pub use local::artifact_inventory::{ArtifactInventoryFrame, ArtifactInventoryVersion};
pub mod os;
#[cfg(test)]
pub(crate) use io_admission::DiskIoClass;
pub(crate) use io_admission::{
acquire_context_disk_io_permit, current_disk_io_context, current_resource_unit, with_optional_disk_io_context,
with_optional_resource_unit,
pub(crate) use io_admission::memory as background_memory;
pub(crate) use io_admission::remote as remote_io_admission;
pub use io_admission::{
BackgroundMemoryBudget, PhysicalDiskBinding, PhysicalDiskPlan, PhysicalDiskSample, PhysicalDiskSampler, PhysicalResourcePlan,
RemoteBackgroundIo, spawn_blocking_with_resource_ownership, with_background_disk_io, with_background_resource_set,
with_background_resource_unit, with_remote_background_io,
};
#[cfg(test)]
pub(crate) use io_admission::{DiskIoClass, test_resource_units};
pub(crate) use io_admission::{
PhysicalHealBinding, acquire_context_disk_io_permit, current_disk_io_context, current_resource_unit, has_resource_io_scope,
with_optional_disk_io_context, with_optional_resource_unit,
};
pub use io_admission::{with_background_disk_io, with_background_resource_unit};
pub const RUSTFS_META_BUCKET: &str = ".rustfs.sys";
pub const MIGRATING_META_BUCKET: &str = ".minio.sys";
@@ -74,10 +79,6 @@ use crate::cluster::rpc::RemoteDisk;
use crate::cluster::rpc::build_internode_data_transport_from_env;
use crate::disk::disk_store::DiskStoreRenameDataExt;
use crate::disk::disk_store::LocalDiskWrapper;
pub use crate::disk::disk_store::{
LegacyFileStream, LegacyFileWrite, LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete,
LegacyPathDeleteOperation, LegacyRenameData, LegacyRenameFile,
};
use crate::disk::health_state::RuntimeDriveHealthState;
use crate::disk::local::ScanGuard;
use bytes::Bytes;
@@ -98,7 +99,6 @@ use uuid::Uuid;
/// Independent admission and physical ownership for one disk rename.
#[derive(Default)]
pub(crate) struct RenameDataGuards {
pub(crate) artifact_put: Option<crate::bucket::artifact::ArtifactPutIdentity>,
pub(crate) scanner_publication_lease_token: Option<Uuid>,
pub(crate) external_guard: Option<Arc<dyn Send + Sync>>,
pub(crate) namespace_owner: Option<Arc<dyn Send + Sync>>,
@@ -240,114 +240,11 @@ pub enum Disk {
}
impl Disk {
/// Capture local file-write admission before authoritative classification.
pub async fn begin_legacy_file_write(&self, volume: &str) -> Result<LegacyFileWrite> {
/// Physical ownership belongs to the node hosting the local attachment.
pub fn physical_admission(&self) -> Option<Arc<PhysicalDiskBinding>> {
match self {
Self::Local(disk) => disk.begin_legacy_file_write(volume).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_file_write_with_namespace_owner(
&self,
volume: &str,
owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyFileWrite> {
match self {
Self::Local(disk) => disk.begin_legacy_file_write_with_namespace_owner(volume, owner).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
/// Capture metadata publication admission before authoritative classification.
pub async fn begin_legacy_metadata_write(&self, volume: &str, path: &str) -> Result<LegacyMetadataWrite> {
match self {
Self::Local(disk) => disk.begin_legacy_metadata_write(volume, path).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_metadata_write_with_namespace_owner(
&self,
volume: &str,
path: &str,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyMetadataWrite> {
match self {
Self::Local(disk) => {
disk.begin_legacy_metadata_write_with_namespace_owner(volume, path, namespace_owner)
.await
}
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) fn record_legacy_admission_failure(
&self,
operation: &'static str,
error: &DiskError,
started: std::time::Instant,
) {
if let Self::Local(disk) = self {
disk.record_legacy_admission_failure(operation, error, started);
}
}
pub(crate) async fn begin_legacy_path_delete(
&self,
volume: &str,
operation: LegacyPathDeleteOperation,
owner: Option<Arc<dyn Send + Sync>>,
deadline: tokio::time::Instant,
) -> Result<LegacyPathDelete> {
match self {
Self::Local(disk) => disk.begin_legacy_path_delete(volume, operation, owner, deadline).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_part_mutation(
&self,
source: Option<(&str, &str)>,
destination: (&str, &str),
namespace_owner: Option<Arc<dyn Send + Sync>>,
operation: LegacyPartOperation,
) -> Result<LegacyPartMutation> {
match self {
Self::Local(disk) => {
disk.begin_legacy_part_mutation(source, destination, namespace_owner, operation)
.await
}
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_rename_file(
&self,
source: (&str, &str),
destination: (&str, &str),
durable: bool,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyRenameFile> {
match self {
Self::Local(disk) => {
disk.begin_legacy_rename_file(source, destination, durable, namespace_owner)
.await
}
Self::Remote(_) => Err(DiskError::DiskNotFound),
}
}
pub(crate) async fn begin_legacy_rename_data(
&self,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Result<LegacyRenameData> {
match self {
Self::Local(disk) => disk.begin_legacy_rename_data(source, fi, destination, namespace_owner).await,
Self::Remote(_) => Err(DiskError::DiskNotFound),
Self::Local(disk) => Some(disk.physical_admission()),
Self::Remote(_) => None,
}
}
@@ -1174,27 +1071,13 @@ impl Disk {
dst_volume: &str,
dst_path: &str,
) -> Result<RenameDataResp> {
let bucket_scope = crate::store::bucket_heal_scope_for_object(dst_volume, dst_path);
let repair_scope = crate::bucket::artifact::artifact_repair_scope(dst_volume, dst_path);
if let Some(scope) = &bucket_scope {
scope.check()?;
}
if let Some(scope) = &repair_scope {
scope.check().map_err(DiskError::other)?;
}
let repair_lease = if let (Self::Local(disk), Some(scope)) = (self, &repair_scope) {
if let Some(identity) = scope.receiver_identity() {
scope.check_version(fi.version_id).await.map_err(DiskError::other)?;
Some(disk.acquire_artifact_repair_lease(identity.receiver_key).await?)
} else {
None
}
} else {
None
let Some(scope) = crate::store::bucket_heal_scope_for_object(dst_volume, dst_path) else {
return self
.rename_data_borrowed_with_fence(src_volume, src_path, fi, dst_volume, dst_path, None)
.await;
};
let owner: Option<Arc<dyn Send + Sync>> = (bucket_scope.is_some() || repair_scope.is_some())
.then(|| Arc::new((bucket_scope, repair_scope, repair_lease)) as Arc<dyn Send + Sync>);
self.rename_data_borrowed_with_fence_and_guard(src_volume, src_path, fi, dst_volume, dst_path, None, owner)
scope.check()?;
self.rename_data_borrowed_with_fence_and_guard(src_volume, src_path, fi, dst_volume, dst_path, None, Some(scope))
.await
}
@@ -1205,20 +1088,10 @@ impl Disk {
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
mut guards: RenameDataGuards,
guards: RenameDataGuards,
) -> RenameDataObservation {
match self {
Disk::Local(local_disk) => {
if let Some(identity) = guards.artifact_put.take() {
let lease = match local_disk.acquire_artifact_put_lease(identity.receiver_key).await {
Ok(lease) => lease,
Err(error) => return RenameDataObservation::unknown(Err(error)),
};
// Keep the existing timeout policy; physical workers own the
// receiver lease even after the caller's disk deadline.
guards.namespace_owner =
Some(Arc::new((guards.external_guard.clone(), guards.namespace_owner.take(), lease)));
}
local_disk
.rename_data_observed_with_guards(src_volume, src_path, fi, dst_volume, dst_path, guards)
.await
@@ -1226,17 +1099,39 @@ impl Disk {
Disk::Remote(remote_disk) => RenameDataObservation::unknown(
remote_disk
.rename_data_borrowed_with_fence(
(src_volume, src_path),
src_volume,
src_path,
fi,
(dst_volume, dst_path),
dst_volume,
dst_path,
guards.scanner_publication_lease_token,
guards.artifact_put.map(|identity| identity.incarnation),
)
.await,
),
}
}
pub(crate) async fn rename_data_borrowed_with_fence(
&self,
src_volume: &str,
src_path: &str,
fi: &FileInfo,
dst_volume: &str,
dst_path: &str,
scanner_publication_lease_token: Option<Uuid>,
) -> Result<RenameDataResp> {
self.rename_data_borrowed_with_fence_and_guard(
src_volume,
src_path,
fi,
dst_volume,
dst_path,
scanner_publication_lease_token,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn rename_data_borrowed_with_fence_and_guard(
&self,
@@ -1257,11 +1152,12 @@ impl Disk {
Disk::Remote(remote_disk) => {
remote_disk
.rename_data_borrowed_with_fence(
(src_volume, src_path),
src_volume,
src_path,
fi,
(dst_volume, dst_path),
dst_volume,
dst_path,
scanner_publication_lease_token,
None,
)
.await
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -76,14 +76,14 @@ impl DriverReadBudget {
}
}
pub(super) fn start_driver(
pub(super) fn start_driver<O: Send + Unpin + 'static>(
&self,
entries: u32,
shards: usize,
) -> Result<rustfs_uring::UringDriver, rustfs_uring::ProbeFailure> {
) -> Result<rustfs_uring::OwnedUringDriver<O>, rustfs_uring::ProbeFailure> {
match self {
Self::Disabled => rustfs_uring::UringDriver::probe_and_start_sharded(entries, shards),
Self::Limited { pool, per_driver } => rustfs_uring::UringDriver::probe_and_start_with_shared_budget(
Self::Disabled => rustfs_uring::OwnedUringDriver::probe_and_start_sharded(entries, shards),
Self::Limited { pool, per_driver } => rustfs_uring::OwnedUringDriver::probe_and_start_with_shared_budget(
entries,
shards,
rustfs_uring::ReadLimits {
@@ -153,13 +153,10 @@ async fn refresh_loss_lock_client_keeps_rejection_observable_for_late_waiters()
);
}
/// Keep the real metadata owner and backing directories alive together.
pub(crate) async fn make_local_set_disks(
drive_count: usize,
parity_count: usize,
) -> (crate::set_disk::HermeticSetFixture, Arc<SetDisks>) {
let (fixture, _, set) = crate::set_disk::hermetic_set_disks_for_pool_with_default_parity(drive_count, 0, parity_count).await;
(fixture, set)
/// Returns the backing [`tempfile::TempDir`]s alongside the set so callers keep
/// them alive for the test's duration and the directories are removed on drop.
pub(crate) async fn make_local_set_disks(drive_count: usize, parity_count: usize) -> (Vec<tempfile::TempDir>, Arc<SetDisks>) {
make_local_set_disks_with_ctx(drive_count, parity_count, crate::runtime::instance::bootstrap_ctx()).await
}
/// Like [`make_local_set_disks`], but binds the set to an explicit instance
@@ -759,9 +756,6 @@ async fn blackbox_range_read_restores_exact_slice_with_one_offline_disk() {
#[tokio::test]
async fn blackbox_delete_marker_hides_object_body_without_erasing_prior_version_metadata() {
let (_dirs, set_disks) = make_local_set_disks(4, 2).await;
let store = crate::bucket::metadata_sys::object_store_in(set_disks.instance_ctx())
.await
.expect("fixture metadata authority should belong to this set");
let bucket = "bb-delete-marker-read-negative";
let object = "object.bin";
let opts = ObjectOptions {
@@ -771,26 +765,13 @@ async fn blackbox_delete_marker_hides_object_body_without_erasing_prior_version_
..Default::default()
};
store
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"body hidden by delete marker".to_vec());
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.put_object(bucket, object, &mut reader, &opts)
.await
.expect("object should be written before delete marker");
+423 -25
View File
@@ -98,6 +98,9 @@ pin_project! {
// contiguous on-disk `[hash][data]` block so both are pulled in a single
// pass; grown lazily and never shrunk.
buf: Vec<u8>,
// Free scratch storage before refunding its allocation.
buf_memory: Option<crate::disk::background_memory::Reservation>,
memory_budget: Option<std::sync::Arc<crate::disk::BackgroundMemoryBudget>>,
// Reused owned chunk vector for the remote HTTP fast path. Keeping the
// allocation with the reader avoids allocating once per bitrot block.
chunks: Vec<bytes::Bytes>,
@@ -118,6 +121,8 @@ where
hash_algo: algo,
shard_size,
buf: Vec::new(),
buf_memory: None,
memory_budget: crate::disk::current_resource_unit().and_then(|unit| unit.memory_budget()),
chunks: Vec::new(),
skip_verify,
last_verify_duration: Duration::ZERO,
@@ -209,6 +214,7 @@ where
/// On `Ok` the block is `self.buf[..need]`; the caller re-borrows it so the
/// verification can also borrow `self` immutably.
async fn read_scratch_block(&mut self, need: usize, want: usize) -> std::io::Result<()> {
reserve_bitrot_scratch(&mut self.buf, &mut self.buf_memory, self.memory_budget.as_ref(), need)?;
if self.buf.len() < need {
self.buf.resize(need, 0);
}
@@ -229,6 +235,28 @@ where
}
}
// Scratch growth happens only before a complete block or an empty chunk tail.
// Old contents are consumed; keep both allocations charged until replacement.
fn reserve_bitrot_scratch(
buf: &mut Vec<u8>,
reservation: &mut Option<crate::disk::background_memory::Reservation>,
budget: Option<&std::sync::Arc<crate::disk::BackgroundMemoryBudget>>,
capacity: usize,
) -> std::io::Result<()> {
if let Some(budget) = budget
&& buf.capacity() < capacity
{
let memory = budget.reserve(capacity)?;
let mut replacement = Vec::new();
replacement
.try_reserve_exact(capacity)
.map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?;
*buf = replacement;
*reservation = Some(memory);
}
Ok(())
}
/// A truncated shard is `UnexpectedEof`, not a short success (backlog#799 B2).
fn short_shard_read(got: usize, want: usize) -> std::io::Error {
error!(
@@ -339,6 +367,8 @@ where
let inner = &mut self.inner;
let chunks = &mut self.chunks;
let tail_buf = &mut self.buf;
let tail_memory = &mut self.buf_memory;
let memory_budget = self.memory_budget.as_ref();
let mut received = 0usize;
poll_fn(|cx| {
for _ in 0..MAX_CHUNK_POLLS_PER_YIELD {
@@ -381,7 +411,9 @@ where
if chunks.len() == MAX_RETAINED_CHUNKS_PER_BLOCK {
if tail_buf.is_empty() {
tail_buf.reserve_exact(need - (received - chunk.len()));
let tail_capacity = need - (received - chunk.len());
reserve_bitrot_scratch(tail_buf, tail_memory, memory_budget, tail_capacity)?;
tail_buf.reserve_exact(tail_capacity);
}
tail_buf.extend_from_slice(&chunk);
} else {
@@ -657,7 +689,12 @@ pub async fn bitrot_verify<R: AsyncRead + Unpin + Send>(
algo: HashAlgorithm,
mut shard_size: usize,
) -> std::io::Result<()> {
let mut hash_buf = vec![0; algo.size()];
// Every supported digest fits on the stack; only shard payloads need leases.
let mut hash = [0; 64];
let hash_buf = &mut hash[..algo.size()];
let mut background_buffer = crate::disk::current_resource_unit()
.and_then(|unit| unit.memory_budget())
.map(|budget| crate::disk::background_memory::BackgroundBuffer::new(Some(budget)));
let mut left = want_size;
if left != bitrot_shard_file_size(part_size, shard_size, algo.clone()) {
@@ -665,21 +702,30 @@ pub async fn bitrot_verify<R: AsyncRead + Unpin + Send>(
}
while left > 0 {
let n = r.read_exact(&mut hash_buf).await?;
let n = r.read_exact(hash_buf).await?;
left -= n;
if left < shard_size {
shard_size = left;
}
let mut buf = get_ec_buffer(shard_size);
buf.resize(shard_size, 0);
let read = r.read_exact(&mut buf).await?;
let mut pooled = None;
let buf = if let Some(buffer) = background_buffer.as_mut() {
buffer.ensure_len(shard_size)?;
&mut buffer.as_mut_slice()[..shard_size]
} else {
let buffer = pooled.insert(get_ec_buffer(shard_size));
buffer.resize(shard_size, 0);
buffer.as_mut_slice()
};
let read = r.read_exact(buf).await?;
let actual_hash = algo.hash_encode(&buf);
let actual_hash = algo.hash_encode(buf);
let hash_ok = actual_hash.as_ref() == &hash_buf[0..n];
drop(actual_hash); // 释放借用
return_ec_buffer(buf);
if let Some(buf) = pooled {
return_ec_buffer(buf);
}
if !hash_ok {
return Err(std::io::Error::other("bitrot hash mismatch"));
}
@@ -698,15 +744,74 @@ pub async fn bitrot_verify<R: AsyncRead + Unpin + Send>(
/// Custom writer enum that supports inline buffer storage
pub enum CustomWriter {
/// Inline buffer writer - stores data in memory
InlineBuffer(Vec<u8>),
InlineBuffer(InlineBuffer),
/// Disk-based writer using tokio file
Other(Box<dyn AsyncWrite + Unpin + Send + Sync>),
}
/// Inline payload storage retains its background allocation through export.
pub struct InlineBuffer {
foreground: Vec<u8>,
background: Option<crate::disk::background_memory::BackgroundBuffer>,
filled: usize,
}
impl InlineBuffer {
fn new() -> Self {
let background = crate::disk::current_resource_unit()
.and_then(|unit| unit.memory_budget())
.map(|budget| crate::disk::background_memory::BackgroundBuffer::new(Some(budget)));
Self {
foreground: Vec::new(),
background,
filled: 0,
}
}
fn as_slice(&self) -> &[u8] {
match &self.background {
Some(buffer) => &buffer.as_slice()[..self.filled],
None => &self.foreground,
}
}
fn append(&mut self, bufs: &[IoSlice<'_>]) -> std::io::Result<usize> {
let total = bufs
.iter()
.try_fold(0usize, |length, buf| length.checked_add(buf.len()).ok_or(std::io::ErrorKind::OutOfMemory))?;
if let Some(buffer) = &mut self.background {
let length = self.filled.checked_add(total).ok_or(std::io::ErrorKind::OutOfMemory)?;
if length > buffer.as_slice().len() {
let capacity = buffer.as_slice().len().checked_mul(2).unwrap_or(length).max(length);
// Admission includes both allocations until the prefix has been copied.
buffer.grow(capacity, self.filled)?;
}
let mut offset = self.filled;
for buf in bufs {
buffer.as_mut_slice()[offset..offset + buf.len()].copy_from_slice(buf);
offset += buf.len();
}
self.filled = length;
} else {
for buf in bufs {
self.foreground.extend_from_slice(buf);
}
}
Ok(total)
}
fn into_bytes(self) -> bytes::Bytes {
match self.background {
Some(mut buffer) => buffer.take_bytes(self.filled),
None => bytes::Bytes::from(self.foreground),
}
}
}
impl CustomWriter {
/// Create a new inline buffer writer
pub fn new_inline_buffer() -> Self {
Self::InlineBuffer(Vec::new())
Self::InlineBuffer(InlineBuffer::new())
}
/// Create a new disk writer from any AsyncWrite implementation
@@ -720,15 +825,15 @@ impl CustomWriter {
/// Get the inline buffer data if this is an inline buffer writer
pub fn get_inline_data(&self) -> Option<&[u8]> {
match self {
Self::InlineBuffer(data) => Some(data),
Self::InlineBuffer(data) => Some(data.as_slice()),
Self::Other(_) => None,
}
}
/// Extract the inline buffer data, consuming the writer
pub fn into_inline_data(self) -> Option<Vec<u8>> {
pub fn into_inline_data(self) -> Option<bytes::Bytes> {
match self {
Self::InlineBuffer(data) => Some(data),
Self::InlineBuffer(data) => Some(data.into_bytes()),
Self::Other(_) => None,
}
}
@@ -741,10 +846,7 @@ impl AsyncWrite for CustomWriter {
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match self.get_mut() {
Self::InlineBuffer(data) => {
data.extend_from_slice(buf);
std::task::Poll::Ready(Ok(buf.len()))
}
Self::InlineBuffer(data) => std::task::Poll::Ready(data.append(&[IoSlice::new(buf)])),
Self::Other(writer) => {
let pinned_writer = std::pin::Pin::new(writer.as_mut());
pinned_writer.poll_write(cx, buf)
@@ -778,13 +880,7 @@ impl AsyncWrite for CustomWriter {
bufs: &[IoSlice<'_>],
) -> std::task::Poll<std::io::Result<usize>> {
match self.get_mut() {
Self::InlineBuffer(data) => {
let total = bufs.iter().map(|buf| buf.len()).sum::<usize>();
for buf in bufs {
data.extend_from_slice(buf);
}
std::task::Poll::Ready(Ok(total))
}
Self::InlineBuffer(data) => std::task::Poll::Ready(data.append(bufs)),
Self::Other(writer) => {
let pinned_writer = std::pin::Pin::new(writer.as_mut());
pinned_writer.poll_write_vectored(cx, bufs)
@@ -859,7 +955,7 @@ impl BitrotWriterWrapper {
}
/// Extract the inline buffer data, consuming the wrapper
pub fn into_inline_data(self) -> Option<Vec<u8>> {
pub fn into_inline_data(self) -> Option<bytes::Bytes> {
match self.writer_type {
WriterType::InlineBuffer => {
let writer = self.bitrot_writer.into_inner();
@@ -1506,6 +1602,184 @@ mod tests {
assert_eq!(data, &out[..]);
}
#[tokio::test]
async fn bitrot_reader_scratch_budget_preserves_growth_and_reuse() {
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
let algo = HashAlgorithm::HighwayHash256S;
let mut encoded = Vec::new();
for data in [&b"abcd"[..], &b"abcdefgh"[..], &b"ijklmnop"[..]] {
encoded.extend_from_slice(algo.hash_encode(data).as_ref());
encoded.extend_from_slice(data);
}
let memory = BackgroundMemoryBudget::new();
let (resources, _) = test_resource_units(1);
let mut reader = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BitrotReader::new(Cursor::new(encoded), 8, algo, false)
})
.await;
let mut first = [0; 4];
assert_eq!(
reader.read(&mut first).await.expect_err("closed scratch budget").kind(),
io::ErrorKind::WouldBlock
);
assert_eq!(reader.inner.position(), 0, "allocation denial must not consume a frame");
memory.set_sampled_limit(36, Duration::from_secs(30));
reader.read(&mut first).await.expect("first scratch allocation fits");
assert_eq!(&first, b"abcd");
assert_eq!(memory.snapshot().used, 36);
memory.set_sampled_limit(75, Duration::from_secs(30));
let mut next = [0; 8];
assert_eq!(
reader
.read(&mut next)
.await
.expect_err("growth requires old36 plus new40")
.kind(),
io::ErrorKind::WouldBlock
);
assert_eq!(reader.inner.position(), 36);
assert_eq!(memory.snapshot().used, 36);
memory.set_sampled_limit(76, Duration::from_secs(30));
reader.read(&mut next).await.expect("growth now fits");
assert_eq!(&next, b"abcdefgh");
assert_eq!(memory.snapshot().used, 40);
memory.set_sampled_limit(0, Duration::ZERO);
reader.read(&mut next).await.expect("reuse after hot shrink");
assert_eq!(&next, b"ijklmnop");
assert_eq!(memory.snapshot().used, 40);
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn bitrot_chunk_tail_allocation_stays_charged_until_reader_drop() {
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
let algo = HashAlgorithm::HighwayHash256S;
let payload = vec![37; 256];
let encoded = encode_one_block(&payload, payload.len(), algo.clone()).await;
let tail_capacity = encoded.len() - MAX_RETAINED_CHUNKS_PER_BLOCK;
for fits in [false, true] {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(if fits { tail_capacity as u64 } else { 0 }, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let mut reader = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
BitrotReader::new(GeneratedChunkSource::new(encoded.clone(), 1), payload.len(), algo.clone(), false)
})
.await;
let mut output = Vec::with_capacity(payload.len());
let result = reader.read_appending(&mut output, payload.len()).await;
if fits {
assert_eq!(result.expect("accounted tail fits"), payload.len());
assert_eq!(output, payload);
assert_eq!(memory.snapshot().used, tail_capacity as u64);
memory.set_sampled_limit(0, Duration::ZERO);
assert_eq!(memory.snapshot().used, tail_capacity as u64);
} else {
assert_eq!(result.expect_err("closed tail budget").kind(), io::ErrorKind::WouldBlock);
assert!(output.is_empty(), "an unverified fragment must not reach output");
assert_eq!(memory.snapshot().used, 0);
}
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn bitrot_verify_background_memory_is_bounded_and_reused() {
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
struct ShrinkingReader {
data: Cursor<Vec<u8>>,
memory: Arc<BackgroundMemoryBudget>,
first_block_end: u64,
peak: Arc<std::sync::atomic::AtomicU64>,
}
impl AsyncRead for ShrinkingReader {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, out: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
self.peak.fetch_max(self.memory.snapshot().used, Ordering::SeqCst);
let result = Pin::new(&mut self.data).poll_read(cx, out);
if self.data.position() >= self.first_block_end {
self.memory.set_sampled_limit(0, Duration::ZERO);
}
result
}
}
for algo in [
HashAlgorithm::None,
HashAlgorithm::HighwayHash256S,
HashAlgorithm::HighwayHash256SLegacy,
] {
let payload = [37; 17];
let mut writer = BitrotWriter::new(Cursor::new(Vec::new()), 8, algo.clone());
for chunk in payload.chunks(8) {
writer.write(chunk).await.expect("encoded bitrot block");
}
writer.shutdown().await.expect("complete fixture");
let encoded = writer.into_inner().into_inner();
let memory = BackgroundMemoryBudget::new();
let (resources, _) = test_resource_units(1);
let error = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
bitrot_verify(Cursor::new(&encoded), encoded.len(), payload.len(), algo.clone(), 8),
)
.await
.expect_err("a closed background budget must reject bitrot payload allocation");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert_eq!(memory.snapshot().used, 0);
memory.set_sampled_limit(8, Duration::from_secs(30));
let peak = Arc::new(std::sync::atomic::AtomicU64::new(0));
let reader = ShrinkingReader {
data: Cursor::new(encoded.clone()),
memory: memory.clone(),
first_block_end: (algo.size() + 8) as u64,
peak: peak.clone(),
};
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
bitrot_verify(reader, encoded.len(), payload.len(), algo, 8),
)
.await
.expect("reuse the same allocation after shrink, including the short tail");
assert_eq!(peak.load(Ordering::SeqCst), 8);
assert_eq!(memory.snapshot().limit, 0);
assert_eq!(
memory.snapshot().used,
0,
"the verifier frees its payload instead of retaining it in an uncharged pool"
);
}
}
#[tokio::test]
async fn bitrot_verify_cancelled_payload_read_refunds_memory() {
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
let algo = HashAlgorithm::HighwayHash256S;
let (mut sender, reader) = tokio::io::duplex(64);
sender
.write_all(algo.hash_encode(&[37; 8]).as_ref())
.await
.expect("send only hash");
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(8, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let mut verify = Box::pin(with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
bitrot_verify(reader, algo.size() + 8, 8, algo, 8),
));
assert!(futures::poll!(&mut verify).is_pending(), "the payload read waits after the complete hash");
let retained = memory.snapshot().used;
memory.set_sampled_limit(0, Duration::ZERO);
drop(verify);
drop(sender);
assert_eq!(retained, 8, "pending verification owns its payload allocation");
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn bitrot_verify_accepts_valid_shard_file_and_rejects_size_or_hash_mismatch() {
let data = b"bitrot verify covers every shard";
@@ -2403,4 +2677,128 @@ mod tests {
assert_eq!(output, vec![9u8]);
}
}
mod inline_memory_regressions {
use super::*;
use crate::disk::background_memory::is_memory_pressure;
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
async fn writer_for(memory: Arc<BackgroundMemoryBudget>) -> CustomWriter {
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(memory)), async { CustomWriter::new_inline_buffer() })
.await
}
#[tokio::test]
async fn inline_writer_rejects_closed_and_expired_owners_before_first_allocation() {
for expired in [false, true] {
let memory = BackgroundMemoryBudget::new();
if expired {
memory.set_sampled_limit(128, Duration::ZERO);
}
let mut writer = writer_for(memory.clone()).await;
let error = writer.write_all(&[7; 64]).await.expect_err("no live grant");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert!(is_memory_pressure(&error));
assert_eq!(writer.get_inline_data(), Some(&[][..]));
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn inline_writer_growth_charges_old_plus_new_and_is_atomic_for_vectored_writes() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(128, Duration::from_secs(30));
let mut writer = writer_for(memory.clone()).await;
writer.write_all(&[1; 64]).await.expect("first allocation");
assert_eq!(memory.snapshot().used, 64);
let second = [2; 32];
let third = [3; 32];
let chunks = [IoSlice::new(&second), IoSlice::new(&third)];
let error = writer.write_vectored(&chunks).await.expect_err("64 old + 128 new exceed 128");
assert!(is_memory_pressure(&error));
assert_eq!(writer.get_inline_data(), Some(&[1; 64][..]));
assert_eq!(memory.snapshot().used, 64);
memory.set_sampled_limit(192, Duration::from_secs(30));
assert_eq!(writer.write_vectored(&chunks).await.expect("old and new both fit"), 64);
assert_eq!(memory.snapshot().used, 128);
let data = writer.get_inline_data().expect("inline payload");
assert_eq!(&data[..64], &[1; 64]);
assert_eq!(&data[64..96], &second);
assert_eq!(&data[96..], &third);
drop(writer);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn inline_writer_expiry_preserves_charged_storage_but_blocks_growth() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(192, Duration::from_secs(30));
let mut writer = writer_for(memory.clone()).await;
writer.write_all(&[5; 64]).await.expect("existing storage");
memory.set_sampled_limit(192, Duration::ZERO);
let error = writer
.write_all(&[9])
.await
.expect_err("expired sample cannot authorize replacement");
assert!(is_memory_pressure(&error));
assert_eq!(writer.get_inline_data(), Some(&[5; 64][..]));
assert_eq!(memory.snapshot().used, 64);
writer.write_all(&[]).await.expect("empty write needs no allocation");
writer.flush().await.expect("flush does not revoke storage");
writer.shutdown().await.expect("shutdown does not revoke storage");
let bytes = writer.into_inline_data().expect("export after expiry");
assert_eq!(bytes.as_ref(), &[5; 64]);
assert_eq!(memory.snapshot().used, 64);
drop(bytes);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn inline_writer_export_keeps_receipt_through_clones_and_the_last_slice() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(64, Duration::from_secs(30));
let mut writer = writer_for(memory.clone()).await;
writer.write_all(&[11; 64]).await.expect("allocate payload");
let bytes = writer.into_inline_data().expect("export bytes");
let clone = bytes.clone();
let slice = bytes.slice(3..7);
memory.set_sampled_limit(0, Duration::ZERO);
drop(bytes);
drop(clone);
assert_eq!(slice.as_ref(), &[11; 4]);
assert_eq!(memory.snapshot().used, 64);
drop(slice);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn inline_writer_keeps_the_constructing_owner_after_leaving_its_scope() {
let first = BackgroundMemoryBudget::new();
first.set_sampled_limit(64, Duration::from_secs(30));
let mut writer = writer_for(first.clone()).await;
let second = BackgroundMemoryBudget::new();
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(second.clone())), async {
writer.write_all(&[13; 64]).await.expect("writer still owns the first grant");
})
.await;
assert_eq!(first.snapshot().used, 64);
assert_eq!(second.snapshot().used, 0);
drop(writer);
assert_eq!(first.snapshot().used, 0);
}
#[tokio::test]
async fn foreground_inline_writer_preserves_payload_and_empty_writes() {
let mut writer = CustomWriter::new_inline_buffer();
writer.write_all(&[]).await.expect("empty foreground write");
writer.write_all(b"inline body").await.expect("foreground write");
let bytes = writer.into_inline_data().expect("foreground export");
assert_eq!(bytes.as_ref(), b"inline body");
}
}
}
+208 -33
View File
@@ -16,6 +16,8 @@
//! Supports legacy (reed-solomon-simd) for reading/healing old-version files.
//!
use crate::disk::background_memory::{BackgroundBuffer, Reservation};
use crate::disk::{BackgroundMemoryBudget, current_resource_unit};
use bytes::{Bytes, BytesMut};
use reed_solomon_simd;
use rustfs_erasure_codec::galois_8::ReedSolomon;
@@ -144,12 +146,54 @@ pub fn calc_shard_size_legacy(block_size: usize, data_shards: usize) -> usize {
/// Reed-Solomon encoder for legacy (main branch) format using reed-solomon-simd.
/// Used when decoding/encoding files with uses_legacy_checksum == true.
struct LegacyWorkspace<T> {
codec: T,
// The cached payload is freed before its reservation.
memory: Option<Reservation>,
payload_bound: usize,
}
impl<T> LegacyWorkspace<T> {
fn prepare(
cached: Option<Self>,
payload_bound: usize,
budget: Option<&Arc<BackgroundMemoryBudget>>,
create: impl FnOnce() -> io::Result<T>,
reset: impl FnOnce(&mut T) -> io::Result<()>,
) -> io::Result<Self> {
if let Some(mut cached) = cached {
let reusable = match (&cached.memory, budget) {
(None, None) => true,
(Some(memory), budget) => {
payload_bound <= cached.payload_bound && budget.is_none_or(|budget| memory.belongs_to(budget))
}
// Uncharged foreground caches can have larger retained Vec
// capacities after reset; start a measured background workspace.
(None, Some(_)) => false,
};
if reusable && reset(&mut cached.codec).is_ok() {
return Ok(cached);
}
// A fresh allocation avoids hidden geometric Vec growth. No payload
// references escape codec calls, so dropping this cache is safe.
drop(cached);
}
let memory = budget.map(|budget| budget.reserve(payload_bound)).transpose()?;
let codec = create()?;
Ok(Self {
codec,
memory,
payload_bound,
})
}
}
struct LegacyReedSolomonEncoder {
data_shards: usize,
parity_shards: usize,
cache_workspaces: bool,
encoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonEncoder>>,
decoder_cache: RwLock<Option<reed_solomon_simd::ReedSolomonDecoder>>,
encoder_cache: RwLock<Option<LegacyWorkspace<reed_solomon_simd::ReedSolomonEncoder>>>,
decoder_cache: RwLock<Option<LegacyWorkspace<reed_solomon_simd::ReedSolomonDecoder>>>,
}
impl LegacyReedSolomonEncoder {
@@ -199,20 +243,29 @@ impl LegacyReedSolomonEncoder {
.write()
.map_err(|_| io::Error::other("Failed to acquire encoder cache lock"))?
.take();
let mut encoder = {
match cached_encoder {
Some(mut cached) => {
if cached.reset(self.data_shards, self.parity_shards, shard_len).is_err() {
reed_solomon_simd::ReedSolomonEncoder::new(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to create SIMD encoder: {e:?}")))?
} else {
cached
}
}
None => reed_solomon_simd::ReedSolomonEncoder::new(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to create SIMD encoder: {e:?}")))?,
}
};
let budget = current_resource_unit().and_then(|resources| resources.memory_budget());
// reed-solomon-simd 3.1.0 Shards::resize allocates 64-byte units. A
// fresh Vec has at least four units; the decoder work-count bound also
// covers both encoder rate strategies.
let payload_bound = self
.logical_shard_bytes_upper_bound(shard_len)
.ok_or(io::ErrorKind::OutOfMemory)?
.max(256);
let mut workspace = LegacyWorkspace::prepare(
cached_encoder,
payload_bound,
budget.as_ref(),
|| {
reed_solomon_simd::ReedSolomonEncoder::new(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to create SIMD encoder: {e:?}")))
},
|encoder| {
encoder
.reset(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to reset SIMD encoder: {e:?}")))
},
)?;
let encoder = &mut workspace.codec;
for (i, shard) in shards.iter().enumerate().take(self.data_shards) {
encoder
.add_original_shard(shard)
@@ -233,7 +286,7 @@ impl LegacyReedSolomonEncoder {
.write()
.map_err(|_| io::Error::other("Failed to return encoder to cache"))?;
if cache.is_none() {
*cache = Some(encoder);
*cache = Some(workspace);
}
}
Ok(())
@@ -254,21 +307,26 @@ impl LegacyReedSolomonEncoder {
.write()
.map_err(|_| io::Error::other("Failed to acquire decoder cache lock"))?
.take();
let mut decoder = {
match cached_decoder {
Some(mut cached_decoder) => {
if let Err(e) = cached_decoder.reset(self.data_shards, self.parity_shards, shard_len) {
warn!("Failed to reset SIMD decoder: {:?}, creating new one", e);
reed_solomon_simd::ReedSolomonDecoder::new(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to create SIMD decoder: {e:?}")))?
} else {
cached_decoder
}
}
None => reed_solomon_simd::ReedSolomonDecoder::new(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to create SIMD decoder: {e:?}")))?,
}
};
let budget = current_resource_unit().and_then(|resources| resources.memory_budget());
let payload_bound = self
.logical_shard_bytes_upper_bound(shard_len)
.ok_or(io::ErrorKind::OutOfMemory)?
.max(256);
let mut workspace = LegacyWorkspace::prepare(
cached_decoder,
payload_bound,
budget.as_ref(),
|| {
reed_solomon_simd::ReedSolomonDecoder::new(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to create SIMD decoder: {e:?}")))
},
|decoder| {
decoder
.reset(self.data_shards, self.parity_shards, shard_len)
.map_err(|e| io::Error::other(format!("Failed to reset SIMD decoder: {e:?}")))
},
)?;
let decoder = &mut workspace.codec;
for (i, shard_opt) in shards.iter().enumerate() {
if let Some(shard) = shard_opt {
@@ -308,7 +366,7 @@ impl LegacyReedSolomonEncoder {
.write()
.map_err(|_| io::Error::other("Failed to return decoder to cache"))?;
if cache.is_none() {
*cache = Some(decoder);
*cache = Some(workspace);
}
}
@@ -333,6 +391,13 @@ impl LegacyReedSolomonEncoder {
return Ok(true);
}
let expected_bytes = shards.iter().try_fold(0usize, |total, shard| {
total
.checked_add(shard.len())
.ok_or_else(|| io::Error::from(io::ErrorKind::OutOfMemory))
})?;
let memory = current_resource_unit().and_then(|resources| resources.memory_budget());
let _expected_memory = memory.as_ref().map(|memory| memory.reserve(expected_bytes)).transpose()?;
let mut expected = shards.iter().map(|shard| Some(shard.to_vec())).collect::<Vec<_>>();
self.encode_parity(&mut expected)?;
@@ -431,6 +496,17 @@ impl ReedSolomonEncoder {
}
if let Some(ref rs) = self.encoder {
if let Some(memory) = current_resource_unit().and_then(|resources| resources.memory_budget())
&& let Some(shard_len) = shards.first().map(|shard| shard.len()).filter(|len| *len != 0)
{
let scratch_len = shard_len.checked_mul(self.parity_shards).ok_or(io::ErrorKind::OutOfMemory)?;
let mut scratch = BackgroundBuffer::new(Some(memory));
scratch.ensure_len(scratch_len)?;
let mut buffers: SmallVec<[&mut [u8]; 16]> = scratch.as_mut_slice().chunks_mut(shard_len).collect();
return rs
.verify_with_buffer(shards, &mut buffers)
.map_err(|e| io::Error::other(format!("Reed-Solomon verify failed: {e:?}")));
}
rs.verify(shards)
.map_err(|e| io::Error::other(format!("Reed-Solomon verify failed: {e:?}")))
} else {
@@ -1200,6 +1276,105 @@ impl Erasure {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn background_legacy_workspaces_keep_their_memory_in_cache() {
use crate::disk::{test_resource_units, with_optional_resource_unit};
use std::time::Duration;
let codec = LegacyReedSolomonEncoder::with_workspace_cache(2, 1, true).expect("isolated cached codec");
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(512, Duration::from_secs(30));
let mut shards = vec![vec![7; 8], vec![9; 8], vec![0; 8]];
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
codec
.encode(shards.iter_mut().map(Vec::as_mut_slice).collect())
.expect("accounted encoder");
})
.await;
assert_eq!(memory.snapshot().used, 256, "fresh SIMD work storage uses at most four 64-byte units");
memory.set_sampled_limit(0, Duration::ZERO);
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
codec
.encode(shards.iter_mut().map(Vec::as_mut_slice).collect())
.expect("cached payload survives shrink");
})
.await;
assert_eq!(memory.snapshot().used, 256);
memory.set_sampled_limit(512, Duration::from_secs(30));
let mut decoded = vec![None, Some(shards[1].clone()), Some(shards[2].clone())];
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
codec.reconstruct_data(&mut decoded).expect("accounted decoder");
})
.await;
assert_eq!(decoded[0].as_deref(), Some(shards[0].as_slice()));
assert_eq!(memory.snapshot().used, 512, "both cached codec payloads remain charged");
drop(codec);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn background_legacy_cache_cannot_reuse_a_different_memory_owner() {
use crate::disk::{test_resource_units, with_optional_resource_unit};
use std::time::Duration;
let codec = LegacyReedSolomonEncoder::with_workspace_cache(2, 1, true).expect("isolated cached codec");
let first = BackgroundMemoryBudget::new();
first.set_sampled_limit(256, Duration::from_secs(30));
let mut shards = vec![vec![7; 8], vec![9; 8], vec![0; 8]];
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(first.clone())), async {
codec
.encode(shards.iter_mut().map(Vec::as_mut_slice).collect())
.expect("first owner");
})
.await;
let second = BackgroundMemoryBudget::new();
let (resources, _) = test_resource_units(1);
let error = with_optional_resource_unit(Some(resources.with_memory_budget(second.clone())), async {
codec.encode(shards.iter_mut().map(Vec::as_mut_slice).collect())
})
.await
.expect_err("a foreign cache must not bypass a closed owner");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert_eq!(first.snapshot().used, 0);
assert_eq!(second.snapshot().used, 0);
}
#[tokio::test]
async fn background_modern_verification_charges_scratch_and_preserves_invalid_input_errors() {
use crate::disk::{test_resource_units, with_optional_resource_unit};
use std::time::Duration;
let codec = ReedSolomonEncoder::new(2, 1).expect("modern codec");
let mut shards = vec![vec![7; 8], vec![9; 8], vec![0; 8]];
codec
.encode(shards.iter_mut().map(Vec::as_mut_slice).collect())
.expect("valid parity");
let refs: Vec<_> = shards.iter().map(Vec::as_slice).collect();
let memory = BackgroundMemoryBudget::new();
let (resources, _) = test_resource_units(1);
let error =
with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async { codec.verify(&refs) })
.await
.expect_err("closed owner rejects verification scratch");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
memory.set_sampled_limit(8, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
assert!(codec.verify(&refs).expect("exact parity scratch fits"));
assert!(
codec.verify(&[&[], &[9; 8], &[0; 8]]).is_err(),
"empty first shard remains an error instead of a panic"
);
})
.await;
assert_eq!(memory.snapshot().used, 0);
}
use proptest::collection::{btree_set, vec};
use proptest::prelude::*;
use std::pin::Pin;
+179 -24
View File
@@ -12,8 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::disk::background_memory::{Reservation, is_memory_pressure};
use crate::disk::disk_store::get_object_disk_read_timeout;
use crate::disk::error::{Error, Result};
use crate::disk::{BackgroundMemoryBudget, current_resource_unit};
use crate::erasure::coding::BitrotReader;
use crate::erasure::coding::BitrotWriterWrapper;
use crate::erasure::coding::encode::MultiWriter;
@@ -22,6 +24,7 @@ use futures::StreamExt;
use futures::stream::FuturesUnordered;
use std::io;
use std::io::ErrorKind;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncRead;
use tracing::{trace, warn};
@@ -30,21 +33,53 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_ERASURE: &str = "erasure";
const EVENT_ERASURE_HEAL_STARTED: &str = "erasure_heal_started";
struct HealShards {
// Drop payloads before refunding their reservations on every error path.
shards: Vec<Option<Vec<u8>>>,
memory: Vec<Option<Reservation>>,
budget: Option<Arc<BackgroundMemoryBudget>>,
}
impl HealShards {
fn reserve_missing(&mut self, shard_size: usize) -> io::Result<()> {
if let Some(budget) = &self.budget {
for (shard, memory) in self.shards.iter().zip(&mut self.memory) {
if shard.is_none() {
*memory = Some(budget.reserve(shard_size)?);
}
}
}
Ok(())
}
fn into_bytes(self) -> Vec<Bytes> {
self.shards
.into_iter()
.zip(self.memory)
.map(|(shard, memory)| Reservation::wrap(Bytes::from(shard.unwrap_or_default()), memory))
.collect()
}
}
async fn read_heal_shards<R>(
readers: &mut [Option<BitrotReader<R>>],
shard_size: usize,
read_timeout: Duration,
) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>)
) -> Result<(HealShards, Vec<Option<Error>>)>
where
R: AsyncRead + Unpin + Send + Sync,
{
let num_readers = readers.len();
let mut shards = vec![None; num_readers];
let mut shards = HealShards {
shards: vec![None; num_readers],
memory: std::iter::repeat_with(|| None).take(num_readers).collect(),
budget: current_resource_unit().and_then(|resources| resources.memory_budget()),
};
let mut errs = vec![None; num_readers];
let mut retire_readers = Vec::new();
if shard_size == 0 {
return (shards, errs);
return Ok((shards, errs));
}
{
@@ -55,8 +90,17 @@ where
continue;
};
let budget = shards.budget.clone();
futures.push(Box::pin(async move {
let mut buf = vec![0; shard_size];
let memory = match budget.as_ref().map(|budget| budget.reserve(shard_size)).transpose() {
Ok(memory) => memory,
Err(error) => return (index, Err(Error::from(error)), false),
};
let mut buf = Vec::new();
if buf.try_reserve_exact(shard_size).is_err() {
return (index, Err(Error::from(io::Error::from(ErrorKind::OutOfMemory))), false);
}
buf.resize(shard_size, 0);
let read_result = if read_timeout.is_zero() {
reader.read(&mut buf).await
} else {
@@ -75,7 +119,7 @@ where
match read_result {
Ok(n) => {
buf.truncate(n);
(index, Ok(buf), false)
(index, Ok((buf, memory)), false)
}
Err(err) => {
let should_retire = err.kind() == ErrorKind::TimedOut;
@@ -87,10 +131,14 @@ where
while let Some((index, result, should_retire)) = futures.next().await {
match result {
Ok(shard) => {
shards[index] = Some(shard);
Ok((shard, memory)) => {
shards.shards[index] = Some(shard);
shards.memory[index] = memory;
}
Err(err) => {
if matches!(&err, Error::Io(error) if is_memory_pressure(error)) {
return Err(err);
}
errs[index] = Some(err);
if should_retire {
retire_readers.push(index);
@@ -105,7 +153,7 @@ where
warn!(shard_index = index, "retiring timed-out heal shard reader");
}
(shards, errs)
Ok((shards, errs))
}
fn heal_writer_failure(writers: &mut MultiWriter<'_>, error: io::Error) -> Error {
@@ -164,7 +212,7 @@ impl super::Erasure {
for block_index in start_block..end_block {
let shard_offset = block_index * self.shard_size();
let shard_size = self.shard_size().min(shard_file_size.saturating_sub(shard_offset));
let (mut shards, errs) = read_heal_shards(&mut readers, shard_size, read_timeout).await;
let (mut owned_shards, errs) = read_heal_shards(&mut readers, shard_size, read_timeout).await?;
// Every source shard is already bitrot-verified by its reader, so any
// data_shards survivors are sufficient to reconstruct — requiring more
@@ -183,6 +231,24 @@ impl super::Erasure {
return Err(Error::ErasureReadQuorum);
}
owned_shards.reserve_missing(shard_size)?;
let source_parity_bytes =
owned_shards
.shards
.iter()
.skip(self.data_shards)
.flatten()
.try_fold(0usize, |total, shard| {
total
.checked_add(shard.len())
.ok_or_else(|| io::Error::from(ErrorKind::OutOfMemory))
})?;
let _source_memory = owned_shards
.budget
.as_ref()
.map(|budget| budget.reserve(source_parity_bytes))
.transpose()?;
let shards = &mut owned_shards.shards;
let source_parity = shards
.iter()
.enumerate()
@@ -190,24 +256,23 @@ impl super::Erasure {
.filter_map(|(index, shard)| shard.as_ref().map(|shard| (index, shard.clone())))
.collect::<Vec<_>>();
if self.parity_shards > 0 {
self.decode_data_and_parity(&mut shards)?;
if !source_parity.is_empty() && !self.verify_data_and_parity(&shards)? {
self.decode_data_and_parity(shards)?;
if !source_parity.is_empty() && !self.verify_data_and_parity(shards)? {
return Err(Error::other("can not reconstruct data: inconsistent heal source shards"));
}
for (index, source) in source_parity {
let Some(rebuilt) = shards[index].as_ref() else {
for (index, source) in &source_parity {
let Some(rebuilt) = shards[*index].as_ref() else {
return Err(Error::other("can not reconstruct data: missing rebuilt parity shard"));
};
if rebuilt != &source {
if rebuilt != source {
return Err(Error::other("can not reconstruct data: inconsistent heal source shards"));
}
}
}
let shards = shards
.into_iter()
.map(|s| Bytes::from(s.unwrap_or_default()))
.collect::<Vec<_>>();
drop(source_parity);
drop(_source_memory);
let shards = owned_shards.into_bytes();
if let Err(error) = writers.write(shards).await {
return Err(heal_writer_failure(&mut writers, error));
@@ -306,6 +371,90 @@ mod tests {
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), shard_size, HashAlgorithm::None)
}
#[tokio::test]
async fn heal_shards_retain_memory_through_missing_outputs_and_last_slice() {
use crate::disk::{test_resource_units, with_optional_resource_unit};
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(24, Duration::from_secs(30));
let mut readers = vec![
Some(BitrotReader::new(Cursor::new(vec![7; 8]), 8, HashAlgorithm::None, false)),
None,
Some(BitrotReader::new(Cursor::new(vec![9; 8]), 8, HashAlgorithm::None, false)),
];
let (resources, _) = test_resource_units(1);
let (mut shards, _) = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
read_heal_shards(&mut readers, 8, Duration::ZERO),
)
.await
.expect("read two accounted shards");
assert_eq!(memory.snapshot().used, 16);
shards
.reserve_missing(8)
.expect("reserve the reconstructed output before allocation");
shards.shards[1] = Some(vec![11; 8]);
assert_eq!(memory.snapshot().used, 24);
let bytes = shards.into_bytes();
let slice = bytes[1].slice(1..3);
memory.set_sampled_limit(0, Duration::ZERO);
drop(bytes);
assert_eq!(slice.as_ref(), &[11; 2]);
assert_eq!(memory.snapshot().used, 8);
drop(slice);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn heal_memory_denial_is_not_replaced_with_read_quorum() {
use crate::disk::{test_resource_units, with_optional_resource_unit};
let erasure = Erasure::new(2, 1, 16);
let encoded = erasure.encode_data(&[37; 16]).expect("valid source shards");
for limit in [0, 23, 31, 39] {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(limit, Duration::from_secs(30));
let readers = encoded
.iter()
.map(|shard| Some(BitrotReader::new(Cursor::new(shard.clone()), 8, HashAlgorithm::None, false)))
.collect();
let mut writers = vec![Some(inline_writer(8)), None, None];
let (resources, _) = test_resource_units(1);
let result = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
erasure.heal(&mut writers, readers, 16, &[]),
)
.await;
assert!(
matches!(result, Err(Error::Io(error)) if error.kind() == ErrorKind::WouldBlock),
"limit={limit}"
);
assert_eq!(memory.snapshot().used, 0);
}
}
#[tokio::test]
async fn cancelled_heal_shard_reads_release_allocated_payloads() {
use crate::disk::{test_resource_units, with_optional_resource_unit};
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(16, Duration::from_secs(30));
let mut readers = vec![
Some(BitrotReader::new(PendingReader, 8, HashAlgorithm::None, false)),
Some(BitrotReader::new(PendingReader, 8, HashAlgorithm::None, false)),
];
let (resources, _) = test_resource_units(1);
let mut read = Box::pin(with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
read_heal_shards(&mut readers, 8, Duration::ZERO),
));
assert!(futures::poll!(&mut read).is_pending());
assert_eq!(memory.snapshot().used, 16);
memory.set_sampled_limit(0, Duration::ZERO);
drop(read);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn read_heal_shards_returns_empty_slots_for_zero_sized_shards() {
let mut readers = vec![
@@ -313,9 +462,11 @@ mod tests {
None,
];
let (shards, errs) = read_heal_shards(&mut readers, 0, Duration::ZERO).await;
let (shards, errs) = read_heal_shards(&mut readers, 0, Duration::ZERO)
.await
.expect("empty read succeeds");
assert_eq!(shards, vec![None, None]);
assert_eq!(shards.shards, vec![None, None]);
assert_eq!(errs, vec![None, None]);
}
@@ -323,9 +474,11 @@ mod tests {
async fn read_heal_shards_records_reader_errors_and_retiring_timeouts() {
let mut readers = vec![Some(BitrotReader::new(FailingReader, 4, HashAlgorithm::None, false))];
let (shards, errs) = read_heal_shards(&mut readers, 4, Duration::ZERO).await;
let (shards, errs) = read_heal_shards(&mut readers, 4, Duration::ZERO)
.await
.expect("ordinary failures remain per-shard");
assert!(shards[0].is_none());
assert!(shards.shards[0].is_none());
assert!(
errs[0]
.as_ref()
@@ -334,9 +487,11 @@ mod tests {
assert!(readers[0].is_some(), "ordinary read errors must not retire readers");
let mut timeout_readers = vec![Some(BitrotReader::new(PendingReader, 4, HashAlgorithm::None, false))];
let (shards, errs) = read_heal_shards(&mut timeout_readers, 4, Duration::from_millis(1)).await;
let (shards, errs) = read_heal_shards(&mut timeout_readers, 4, Duration::from_millis(1))
.await
.expect("timeouts remain per-shard");
assert_eq!(shards, vec![None]);
assert_eq!(shards.shards, vec![None]);
assert!(errs[0].as_ref().is_some_and(|err| err.to_string().contains("timed out")));
assert!(timeout_readers[0].is_none(), "timed-out readers are retired");
}
+122 -4
View File
@@ -1536,7 +1536,7 @@ mod tests {
writer.write(chunk).await.expect("write chunk");
}
let inline_data = Bytes::from(writer.into_inline_data().expect("inline buffer"));
let inline_data = writer.into_inline_data().expect("inline buffer");
let mut reader = create_bitrot_reader_from_bytes(
Some(inline_data),
None,
@@ -1584,7 +1584,7 @@ mod tests {
let inline_data = writer.into_inline_data().expect("inline buffer");
let mut reader = create_deferred_bitrot_reader(
Some(inline_data.into()),
Some(inline_data),
None,
"test-bucket",
"test-path",
@@ -1626,7 +1626,7 @@ mod tests {
for stripe in 0..payload.len() / shard_size {
let (mut reader, handle) = create_deferred_bitrot_reader_with_stripe_handle(
Some(inline_data.clone().into()),
Some(inline_data.clone()),
None,
"test-bucket",
"test-path",
@@ -1671,7 +1671,7 @@ mod tests {
for chunk in payload.chunks(shard_size) {
writer.write(chunk).await.expect("write chunk");
}
let mut inline_data = writer.into_inline_data().expect("inline buffer");
let mut inline_data = writer.into_inline_data().expect("inline buffer").to_vec();
// Corrupt one payload byte of block 2 (blocks are hash + data).
let block_len = algo.size() + shard_size;
@@ -1845,4 +1845,122 @@ mod tests {
assert_eq!(output, data);
}
mod inline_memory_regressions {
use super::*;
use crate::disk::background_memory::Reservation;
use crate::disk::{BackgroundMemoryBudget, test_resource_units, with_optional_resource_unit};
use std::time::Duration;
#[tokio::test]
async fn owned_inline_reader_retains_existing_receipt_without_a_new_grant() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(64, Duration::from_secs(30));
let receipt = memory.reserve(64).expect("existing metadata payload grant");
let bytes = Reservation::wrap(Bytes::from(vec![7; 64]), Some(receipt));
memory.set_sampled_limit(0, Duration::ZERO);
let (resources, _) = test_resource_units(1);
let mut reader = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
create_bitrot_reader_from_bytes(
Some(bytes.clone()),
None,
"bucket",
"inline",
0,
64,
64,
HashAlgorithm::None,
false,
false,
),
)
.await
.expect("owned data requires no allocation")
.expect("inline reader");
drop(bytes);
assert_eq!(memory.snapshot().used, 64, "the source owner must remain attached to the reader");
let mut output = [0; 64];
assert_eq!(reader.read(&mut output).await.expect("read existing payload"), 64);
assert_eq!(output, [7; 64]);
assert_eq!(memory.snapshot().used, 64);
drop(reader);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn accounted_inline_writer_round_trips_bitrot_and_preserves_export_lifetime() {
let memory = BackgroundMemoryBudget::new();
memory.set_sampled_limit(192, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let bytes = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
let mut writer = create_bitrot_writer(true, None, "bucket", "inline", 64, 64, HashAlgorithm::HighwayHash256S)
.await
.expect("inline writer");
assert_eq!(writer.write(&[19; 64]).await.expect("complete shard"), 64);
writer.shutdown().await.expect("shutdown");
writer.into_inline_data().expect("bitrot bytes")
})
.await;
assert_eq!(bytes.len(), 96);
assert_eq!(memory.snapshot().used, 96, "exported [hash][shard] remains charged");
let (resources, _) = test_resource_units(1);
let mut reader = with_optional_resource_unit(
Some(resources.with_memory_budget(memory.clone())),
create_bitrot_reader_from_bytes(
Some(bytes.clone()),
None,
"bucket",
"inline",
0,
64,
64,
HashAlgorithm::HighwayHash256S,
false,
false,
),
)
.await
.expect("reader")
.expect("inline reader");
let mut output = [0; 64];
assert_eq!(reader.read(&mut output).await.expect("bitrot remains verified"), 64);
assert_eq!(output, [19; 64]);
assert_eq!(memory.snapshot().used, 192, "input owner and verification scratch are both live");
drop(reader);
assert_eq!(memory.snapshot().used, 96);
let slice = bytes.slice(32..36);
drop(bytes);
assert_eq!(memory.snapshot().used, 96);
drop(slice);
assert_eq!(memory.snapshot().used, 0);
}
#[tokio::test]
async fn inline_heal_writer_memory_denial_is_retryable_after_read_and_verify_fit() {
use crate::erasure::coding::Erasure;
let erasure = Erasure::new(2, 1, 16);
let encoded = erasure.encode_data(&[37; 16]).expect("foreground test fixture");
let readers = encoded
.into_iter()
.map(|shard| Some(BitrotReader::new(Cursor::new(shard), 8, HashAlgorithm::None, false)))
.collect();
let memory = BackgroundMemoryBudget::new();
// Read shards: 24 bytes; source parity: 8; verify scratch: 8.
// After verification, input shards still use 24. The target needs a
// new 40-byte [hash][shard] allocation and must fail before copying.
memory.set_sampled_limit(40, Duration::from_secs(30));
let (resources, _) = test_resource_units(1);
let result = with_optional_resource_unit(Some(resources.with_memory_budget(memory.clone())), async {
let writer = create_bitrot_writer(true, None, "bucket", "inline", 8, 8, HashAlgorithm::HighwayHash256S)
.await
.expect("writer setup does not allocate payload");
let mut writers = vec![Some(writer), None, None];
erasure.heal(&mut writers, readers, 16, &[]).await
})
.await;
assert!(matches!(result, Err(DiskError::Io(error)) if error.kind() == io::ErrorKind::WouldBlock));
assert_eq!(memory.snapshot().used, 0);
}
}
}
+4 -2
View File
@@ -55,8 +55,10 @@ mod set_disk;
mod storage_api_contracts;
mod store;
pub use disk::{with_background_disk_io, with_background_resource_unit};
pub use store::{BucketIncarnationFenceGuard, PoolMetaWriteGateStatus};
pub use disk::{
spawn_blocking_with_resource_ownership, with_background_disk_io, with_background_resource_set, with_background_resource_unit,
};
pub use store::PoolMetaWriteGateStatus;
// pub mod checksum;
mod event;
-4
View File
@@ -4473,7 +4473,3 @@ mod tests {
.expect_err("a lying frame-layout marker must fail closed, not serve plaintext");
}
}
#[cfg(test)]
#[path = "readers/compression_index_compatibility_tests.rs"]
mod compression_index_compatibility_tests;
@@ -1,185 +0,0 @@
use super::*;
use rustfs_utils::http::metadata_compat::{SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, insert_str};
use sha2::{Digest, Sha256};
use std::io::Cursor;
use tokio::io::AsyncReadExt;
const STREAM: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/compression-index/legacy-lz4-stream.bin"
));
const HISTORICAL_INDEX: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/compression-index/pre-zigzag-index.bin"
));
#[cfg(not(feature = "rio-v2"))]
const CURRENT_INDEX: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/compression-index/zigzag-index.bin"));
const PLAINTEXT_LEN: usize = 3 * 1024 * 1024 + 123;
const PLAINTEXT_SHA256: &str = "68f9edf6f9600395d6d2b5517d97570fecd2a0c6dbe6130617ef415a3b48d89c";
fn fixture_info(index: &[u8]) -> ObjectInfo {
let mut metadata = HashMap::new();
insert_str(&mut metadata, SUFFIX_COMPRESSION, "lz4".to_owned());
insert_str(&mut metadata, SUFFIX_ACTUAL_SIZE, PLAINTEXT_LEN.to_string());
ObjectInfo {
size: i64::try_from(STREAM.len()).expect("fixed fixture size fits i64"),
parts: Arc::new(vec![ObjectPartInfo {
number: 1,
size: STREAM.len(),
actual_size: i64::try_from(PLAINTEXT_LEN).expect("fixed plaintext length fits i64"),
index: Some(Bytes::copy_from_slice(index)),
..Default::default()
}]),
user_defined: Arc::new(metadata),
..Default::default()
}
}
fn fixture_plaintext() -> Vec<u8> {
(0..PLAINTEXT_LEN)
.map(|offset| {
let chunk = offset / 1024;
let lane = offset % 1024;
if lane < 8 {
u64::try_from(chunk).expect("fixed chunk number fits u64").to_le_bytes()[lane]
} else {
b"abcdefghijklmnopqrstuvwxyz"[(offset + chunk) % 26]
}
})
.collect()
}
#[tokio::test]
async fn historical_index_preserves_the_complete_compressed_body() {
let info = fixture_info(HISTORICAL_INDEX);
let (mut reader, storage_offset, _) = GetObjectReader::new(
Box::new(Cursor::new(STREAM.to_vec())),
None,
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct reader for fixed historical index");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read complete historical body");
assert_eq!(storage_offset, 0);
assert_eq!(actual.len(), PLAINTEXT_LEN);
assert_eq!(
hex_simd::encode_to_string(Sha256::digest(&actual), hex_simd::AsciiCase::Lower),
PLAINTEXT_SHA256
);
}
#[tokio::test]
async fn historical_index_preserves_ranges_across_compression_blocks() {
let info = fixture_info(HISTORICAL_INDEX);
let expected = fixture_plaintext();
for start in [7, 1024 * 1024 - 17, 2 * 1024 * 1024 + 31, PLAINTEXT_LEN - 97] {
let range = HTTPRangeSpec {
is_suffix_length: false,
start: i64::try_from(start).expect("fixed range start fits i64"),
end: i64::try_from(start + 63).expect("fixed range end fits i64"),
};
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
.await
.expect("plan historical compressed range");
let physical = plan.storage_offset;
let input = STREAM.get(physical..).expect("planned offset stays within the fixed stream");
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(input.to_vec())),
Some(range),
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct historical range reader");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read historical range");
assert_eq!(actual, expected[start..start + 64], "range starting at {start}");
}
}
#[cfg(not(feature = "rio-v2"))]
#[tokio::test]
async fn current_index_selects_a_fixed_compressed_block_for_a_range() {
let info = fixture_info(CURRENT_INDEX);
let start = 2 * 1024 * 1024 + 31;
let range = HTTPRangeSpec {
is_suffix_length: false,
start: i64::try_from(start).expect("fixed range start fits i64"),
end: i64::try_from(start + 63).expect("fixed range end fits i64"),
};
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
.await
.expect("plan range with fixed current index");
assert_eq!(plan.storage_offset, 34_663, "use the recorded second block boundary");
let physical = plan.storage_offset;
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(STREAM[physical..].to_vec())),
Some(range),
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct indexed range reader");
let mut actual = Vec::new();
reader.read_to_end(&mut actual).await.expect("read indexed range");
assert_eq!(actual, fixture_plaintext()[start..start + 64]);
}
#[tokio::test]
async fn truncated_index_falls_back_without_truncating_the_range() {
let info = fixture_info(&HISTORICAL_INDEX[..HISTORICAL_INDEX.len() - 3]);
let start = 2 * 1024 * 1024 + 31;
let range = HTTPRangeSpec {
is_suffix_length: false,
start: i64::try_from(start).expect("fixed range start fits i64"),
end: i64::try_from(start + 96).expect("fixed range end fits i64"),
};
let plan = ReadPlan::build(Some(range.clone()), &info, &ObjectOptions::default(), &HeaderMap::new())
.await
.expect("invalid index still permits a sequential range plan");
assert_eq!(plan.storage_offset, 0, "truncated index must not select a storage offset");
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(STREAM.to_vec())),
Some(range),
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct sequential fallback reader");
let mut actual = Vec::new();
reader
.read_to_end(&mut actual)
.await
.expect("read range without the truncated index");
assert_eq!(actual, fixture_plaintext()[start..start + 97]);
}
#[tokio::test]
async fn historical_index_fallback_does_not_hide_a_body_checksum_failure() {
let info = fixture_info(HISTORICAL_INDEX);
let mut corrupted = STREAM.to_vec();
corrupted[4] ^= 1; // Corrupt the first block's stored CRC, leaving its payload intact.
let (mut reader, _, _) = GetObjectReader::new(
Box::new(Cursor::new(corrupted)),
None,
&info,
&ObjectOptions::default(),
&HeaderMap::new(),
)
.await
.expect("construct reader before body checksum verification");
let mut actual = Vec::new();
let error = reader
.read_to_end(&mut actual)
.await
.expect_err("bad body CRC cannot become successful EOF");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(actual.len() < PLAINTEXT_LEN);
}
+2 -50
View File
@@ -152,7 +152,7 @@ pub struct ObjectLockConfigSnapshot {
config_revision: Option<OffsetDateTime>,
state: crate::bucket::metadata_sys::ObjectLockConfigState,
lifecycle_fence: NamespaceLockFence,
_lifecycle_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
_lifecycle_guard: Option<rustfs_lock::NamespaceLockGuard>,
metadata_transaction_guard: Option<Arc<rustfs_lock::NamespaceLockGuard>>,
}
@@ -210,33 +210,8 @@ impl ObjectLockConfigSnapshot {
config_revision: Some(config_revision),
state,
lifecycle_fence,
_lifecycle_guard: Some(Arc::new(lifecycle_guard)),
metadata_transaction_guard: Some(Arc::new(metadata_transaction_guard)),
}
}
pub(crate) fn for_store_bucket_under_lifecycle_owner(
store_id: Uuid,
bucket: &str,
bucket_incarnation_id: Uuid,
config_revision: OffsetDateTime,
state: crate::bucket::metadata_sys::ObjectLockConfigState,
lifecycle_guard: Arc<rustfs_lock::NamespaceLockGuard>,
metadata_transaction_guard: Arc<rustfs_lock::NamespaceLockGuard>,
) -> Self {
let mut lifecycle_fence = NamespaceLockFence::new();
if let Some(signal) = lifecycle_guard.lock_lost_signal() {
lifecycle_fence.add_signal(signal);
}
Self {
store_id: Some(store_id),
bucket: Some(bucket.to_owned()),
bucket_incarnation_id: Some(bucket_incarnation_id),
config_revision: Some(config_revision),
state,
lifecycle_fence,
_lifecycle_guard: Some(lifecycle_guard),
metadata_transaction_guard: Some(metadata_transaction_guard),
metadata_transaction_guard: Some(Arc::new(metadata_transaction_guard)),
}
}
@@ -279,15 +254,6 @@ impl ObjectLockConfigSnapshot {
&self.state
}
pub(crate) fn is_unbound(&self) -> bool {
self.store_id.is_none()
&& self.bucket.is_none()
&& self.bucket_incarnation_id.is_none()
&& self.config_revision.is_none()
&& self._lifecycle_guard.is_none()
&& self.metadata_transaction_guard.is_none()
}
pub(crate) fn is_valid_for_destructive_put(&self, store_id: Uuid, bucket: &str, bucket_incarnation_id: Uuid) -> bool {
self.store_id == Some(store_id)
&& self.bucket.as_deref() == Some(bucket)
@@ -300,11 +266,6 @@ impl ObjectLockConfigSnapshot {
.is_some_and(|guard| !guard.is_lock_lost())
}
pub(crate) fn owns_bucket_lifecycle_fence(&self, store_id: Uuid, bucket: &str, bucket_incarnation_id: Uuid) -> bool {
self.is_valid_for_destructive_put(store_id, bucket, bucket_incarnation_id)
&& self._lifecycle_guard.as_ref().is_some_and(|guard| !guard.is_lock_lost())
}
/// Share the held transaction lock without queuing another reader behind
/// a metadata writer that is itself waiting for this snapshot to drop.
pub(crate) fn metadata_transaction_guard_for(
@@ -1001,12 +962,6 @@ pub struct ObjectOptions {
/// A genuine target write guard; loss fences alone do not authorize a borrowed write.
#[doc(hidden)]
pub write_lock_context: Option<WriteLockContext>,
/// Ordinary ECStore PUT target bound to its bucket's immutable artifact namespace.
#[doc(hidden)]
pub artifact_put_target: Option<Arc<crate::bucket::artifact::ArtifactPutTarget>>,
/// Owned, non-serializable authority for one fully sealed managed version.
#[doc(hidden)]
pub artifact_delete_target: Option<Arc<crate::bucket::artifact::ArtifactDeleteTarget>>,
/// In-process delete ownership for late filesystem completion. Never
/// serialized or copied into object metadata.
#[doc(hidden)]
@@ -1086,9 +1041,6 @@ pub struct ObjectOptions {
/// Proves an upper layer holds the bucket lifecycle sentinel. A separate
/// fence avoids recursively acquiring the read lock behind a queued writer.
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
/// Actual generation owner retained by storage commits and their physical tails.
#[doc(hidden)]
pub bucket_incarnation_fence: Option<Arc<crate::store::BucketIncarnationFenceGuard>>,
/// Retains the actual lifecycle guard for an owned multipart commit. A
/// lock-loss fence alone does not keep a cancelled caller's lock alive. The
/// owner also retains the continuous incarnation-validation coverage.
@@ -71,7 +71,7 @@ impl WriteCommitGuard {
/// Namespace locks key the disk identity of the name (see `new_ns_lock`),
/// so a guard proves every spelling that resolves to the same file.
pub(crate) fn matches(&self, bucket: &str, object: &str) -> bool {
fn matches(&self, bucket: &str, object: &str) -> bool {
self.bucket.as_ref() == bucket && self.object.as_ref() == object_name_disk_identity(object).as_ref()
}
}
-8
View File
@@ -814,14 +814,6 @@ impl InstanceContext {
self.tier_config_mgr.get_or_init(TierConfigMgr::new).clone()
}
#[cfg(test)]
pub(crate) fn set_test_tier_config_mgr(&self, manager: Arc<RwLock<TierConfigMgr>>) {
assert!(
self.tier_config_mgr.set(manager).is_ok(),
"test tier manager must be installed before use"
);
}
/// This instance's event notifier, materializing it on first access.
/// Shared for the lifetime of the instance.
pub fn event_notifier(&self) -> Arc<RwLock<EventNotifier>> {
+4 -15
View File
@@ -250,7 +250,7 @@ impl ECStore {
}
let bucket_incarnation_fence = match bucket_configs.bucket_incarnation_id {
Some(expected) => Some(Arc::new(self.acquire_bucket_incarnation_fence(&bucket, expected).await?)),
Some(expected) => Some(self.acquire_bucket_incarnation_fence(&bucket, expected).await?),
None => None,
};
@@ -533,8 +533,9 @@ impl ECStore {
&cleanup_transferred_tier_versions,
data_movement::SourceCleanupBucketFence {
expected_incarnation_id: bucket_configs.bucket_incarnation_id,
lifecycle_owner: bucket_incarnation_fence.as_ref(),
lifecycle_guard: None,
lifecycle_guard: bucket_incarnation_fence
.as_ref()
.and_then(|guard| guard.namespace_lock_guard()),
namespace_lock_lost_signal: lock_lost_signal.clone(),
..Default::default()
},
@@ -1728,8 +1729,6 @@ mod tests {
/// Both versions are on the target with their identities, the null
/// version's content and SHA-256 are intact, and the source is gone.
async fn assert_moved(&self) {
use crate::disk::DiskAPI as _;
let target = self.store.pools[1]
.get_disks_by_key(self.object)
.load_file_info_versions_exact(self.bucket, self.object)
@@ -1770,16 +1769,6 @@ mod tests {
self.source_versions().await.is_none(),
"the source entry must be removed only after both versions moved"
);
let source_disks = self.source_set.disk_inventory().await;
assert_eq!(source_disks.iter().flatten().count(), 2, "both source disks must be online");
for disk in source_disks.into_iter().flatten() {
assert!(
!tokio::fs::try_exists(disk.path().join(self.bucket).join(self.object))
.await
.expect("inspect the physical rebalance source path"),
"rebalance must remove source metadata and shards from every disk"
);
}
}
}
@@ -88,7 +88,6 @@ pub async fn test_store_with_persisted_rebalance_meta(
pool_meta_save_gate: tokio::sync::Mutex::default(),
ctx,
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
});
(temp_dirs, store)
}
@@ -267,7 +266,6 @@ async fn test_pool_stores_with_contexts(
pool_meta_save_gate: tokio::sync::Mutex::new(pool_meta_write_state.independent_clone_for_test()),
ctx: store_ctx,
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
})
};
let store = make_store(ctx);
@@ -3415,7 +3415,6 @@ fn test_store_with_rebalance_meta(meta: RebalanceMeta) -> Arc<crate::store::ECSt
pool_meta_save_gate: tokio::sync::Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
})
}
+14 -136
View File
@@ -3053,7 +3053,7 @@ impl RenameRollbackReceipt {
struct RenameRollbackOwnership {
receipt: Option<RenameRollbackReceipt>,
namespace_commit_guard: Option<Arc<dyn Send + Sync>>,
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
}
async fn inspect_incomplete_rename_rollback(
@@ -3169,7 +3169,7 @@ async fn rollback_failed_rename(
old_data_dir: rollback_dir,
..Default::default()
},
disk_namespace_commit_guard,
disk_namespace_commit_guard.map(|owner| owner as Arc<dyn Send + Sync>),
)
.await
});
@@ -3281,11 +3281,10 @@ async fn record_rename_rollback_outcomes(
/// quorum and optional scanner lease map together avoids widening either
/// fanout helper's argument list while preserving the fence semantics.
pub(in crate::set_disk) struct RenameDataFenceOptions<'a> {
artifact_put: Option<crate::bucket::artifact::ArtifactPutIdentity>,
write_quorum: usize,
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
scanner_publication_commit_scope: Option<crate::object_api::ScannerPublicationCommitScope>,
namespace_commit_guard: Option<Arc<dyn Send + Sync>>,
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
rollback_receipt: Option<RenameRollbackReceipt>,
}
@@ -3295,7 +3294,6 @@ impl<'a> RenameDataFenceOptions<'a> {
scanner_publication_lease_tokens: Option<&'a HashMap<String, Uuid>>,
) -> Self {
Self {
artifact_put: None,
write_quorum,
scanner_publication_lease_tokens,
scanner_publication_commit_scope: None,
@@ -3304,14 +3302,6 @@ impl<'a> RenameDataFenceOptions<'a> {
}
}
pub(in crate::set_disk) fn with_artifact_put(
mut self,
identity: Option<crate::bucket::artifact::ArtifactPutIdentity>,
) -> Self {
self.artifact_put = identity;
self
}
pub(in crate::set_disk) fn with_rollback_receipt(mut self, receipt: RenameRollbackReceipt) -> Self {
self.rollback_receipt = Some(receipt);
self
@@ -3327,7 +3317,7 @@ impl<'a> RenameDataFenceOptions<'a> {
pub(in crate::set_disk) fn with_namespace_commit_guard(
mut self,
namespace_commit_guard: Option<Arc<dyn Send + Sync>>,
namespace_commit_guard: Option<Arc<crate::runtime::instance::NamespaceCommitGuard>>,
) -> Self {
self.namespace_commit_guard = namespace_commit_guard;
self
@@ -3780,7 +3770,6 @@ impl SetDisks {
fence_options: RenameDataFenceOptions<'_>,
) -> disk::error::Result<RenameDataCommit> {
let RenameDataFenceOptions {
artifact_put,
write_quorum,
scanner_publication_lease_tokens,
scanner_publication_commit_scope: _scanner_publication_commit_scope,
@@ -3875,10 +3864,11 @@ impl SetDisks {
&file_info,
&dst_bucket,
&dst_object,
crate::disk::RenameDataGuards {
artifact_put,
disk::RenameDataGuards {
scanner_publication_lease_token,
namespace_owner: namespace_commit_guard.clone(),
namespace_owner: namespace_commit_guard
.clone()
.map(|owner| owner as Arc<dyn Send + Sync>),
..Default::default()
},
)
@@ -4187,7 +4177,6 @@ impl SetDisks {
.await;
}
let RenameDataFenceOptions {
artifact_put,
write_quorum,
scanner_publication_lease_tokens,
scanner_publication_commit_scope,
@@ -4308,10 +4297,11 @@ impl SetDisks {
file_info,
&dst_bucket,
&dst_object,
crate::disk::RenameDataGuards {
artifact_put,
disk::RenameDataGuards {
scanner_publication_lease_token,
namespace_owner: namespace_commit_guard.clone(),
namespace_owner: namespace_commit_guard
.clone()
.map(|owner| owner as Arc<dyn Send + Sync>),
..Default::default()
},
)
@@ -8726,11 +8716,11 @@ mod tests {
writer.shutdown().await.expect("inline writer should shutdown");
let mut fi = base.clone();
fi.erasure.index = index + 1;
fi.data = Some(Bytes::from(
fi.data = Some(
writer
.into_inline_data()
.expect("inline bitrot writer should retain encoded data"),
));
);
files.push(fi);
}
files
@@ -12825,118 +12815,6 @@ mod tests {
assert!(missing.below_quorum, "missing disk slots should remain visible in the quorum lens");
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn legacy_path_delete_flow_prefix_delete_keeps_scanner_owner_until_drain() {
use crate::disk::os::{drain_namespace_for_test, prepared_publication_test_hooks as hooks};
use crate::object_api::{
ScannerPublicationCommitScope, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
};
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
use crate::storage_api_contracts::bucket::BucketOperations;
let (_dirs, disks, set) = hermetic_set_disks_isolated(4).await;
let bucket = "path-delete-scanner";
crate::bucket::metadata_sys::object_store_in(&set.ctx)
.await
.expect("fixture object store")
.make_bucket(bucket, &Default::default())
.await
.expect("authoritative bucket creation");
let mut paths = Vec::new();
for disk in &disks {
disk.write_all(bucket, "prefix/part.1", Bytes::from_static(b"old shard"))
.await
.expect("seed prefix on every disk");
paths.push(
disk.get_object_path_for_io_if_local(bucket, "prefix")
.expect("local disk")
.expect("prefix path"),
);
}
let movement = Arc::new(tokio::sync::RwLock::new(()));
let scope = ScannerPublicationCommitScope::new_storage_owned(
7,
tokio::time::Instant::now() + Duration::from_secs(30),
Vec::new(),
movement.clone().read_owned().await,
);
scope.try_begin().expect("begin scanner publication");
let caller_guard = ScannerPublicationCommitScopeGuard::new(scope.clone());
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _hook = hooks::install_at(hooks::Stage::Rename, &paths[0], move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
let owner_set = set.clone();
let owner_scope = scope.clone();
let caller = tokio::spawn(async move {
owner_set
.delete_prefix_with_scanner_publication_lease(bucket, "prefix", None, Some(owner_scope))
.await
});
let entered = tokio::time::timeout(Duration::from_secs(5), entered_rx).await;
caller.abort();
let _ = caller.await;
drop(caller_guard);
let state_while_blocked = scope.state();
let movement_while_blocked = movement.clone().try_write_owned().is_ok();
let disk::Disk::Local(local) = disks[0].as_ref() else {
unreachable!("fixture contains local disks");
};
let local = local.get_disk();
let volume_read_held = crate::disk::os::disk_volume_mutation_lock(&local.root, bucket)
.try_write_owned()
.is_err();
let mut deletion = Box::pin(local.delete_volume(bucket, true));
let early_deletion = futures::poll!(deletion.as_mut());
let deletion_was_blocked = early_deletion.is_pending();
// Always release and drain physical work before reporting an ownership failure.
let _ = release_tx.send(());
let mut drained = Vec::new();
for path in &paths {
drained.push(tokio::time::timeout(Duration::from_secs(5), drain_namespace_for_test(path)).await);
}
let deletion_result = match early_deletion {
std::task::Poll::Ready(result) => result,
std::task::Poll::Pending => tokio::time::timeout(Duration::from_secs(5), deletion)
.await
.expect("volume deletion drains"),
};
let terminal = tokio::time::timeout(Duration::from_secs(5), scope.wait_for_completion()).await;
assert!(
entered.is_ok_and(|result| result.is_ok()),
"real prefix deletion must reach its filesystem worker"
);
assert!(drained.into_iter().all(|result| result.is_ok()), "every physical namespace must drain");
deletion_result.expect("volume deletion after physical drain");
assert!(
volume_read_held && deletion_was_blocked,
"volume deletion must wait for the physical prefix worker"
);
assert_eq!(state_while_blocked, ScannerPublicationCommitState::InFlight);
assert!(!movement_while_blocked, "scanner movement remains fenced after caller cancellation");
assert_eq!(terminal.expect("scanner owner completion"), ScannerPublicationCommitState::Committed);
assert!(scope.release_movement_permit().await, "terminal scanner owner releases movement");
assert!(movement.try_write_owned().is_ok());
for path in paths.iter().skip(1) {
assert!(!path.exists(), "successful prefix fanout removes every surviving volume's prefix");
}
disks[0].make_volume(bucket).await.expect("recreate deleted volume");
disks[0]
.write_all(bucket, "prefix/sentinel", Bytes::from_static(b"new generation"))
.await
.expect("new generation sentinel");
assert_eq!(
disks[0]
.read_all(bucket, "prefix/sentinel")
.await
.expect("sentinel survives old deletion"),
b"new generation".as_slice()
);
}
#[tokio::test]
async fn delete_prefix_succeeds_when_present_disks_reach_quorum() {
let bucket = "delete-prefix-bucket";
+16 -393
View File
@@ -906,8 +906,6 @@ pub(crate) use ops::object::body_cache_plaintext_len;
#[cfg(all(test, feature = "test-util"))]
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
#[cfg(test)]
pub(crate) use ops::object::hermetic_set_disks_support::{HermeticSetFixture, hermetic_set_disks_for_pool_with_default_parity};
#[cfg(test)]
pub(crate) use ops::object::{DeleteCleanupBarrier, DeleteObjectCommitBarrier};
#[cfg(any(test, feature = "test-util"))]
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
@@ -1147,310 +1145,6 @@ mod prepared_get_object_metadata_tests {
use http::HeaderMap;
use tokio::io::AsyncReadExt;
const OBSERVATION_HOOK_BODY_LEN: usize = 1024 * 1024 + 1;
struct ObservationBodyHook {
object: &'static str,
calls: Arc<AtomicU64>,
body: bytes::Bytes,
}
#[async_trait::async_trait]
impl crate::object_api::GetObjectBodyCacheHook for ObservationBodyHook {
async fn lookup(&self, bucket: &str, object: &str, info: &ObjectInfo) -> Option<bytes::Bytes> {
if bucket == crate::disk::RUSTFS_META_BUCKET && object == self.object {
assert_eq!(info.size, i64::try_from(self.body.len()).expect("hook fixture size"));
self.calls.fetch_add(1, Ordering::SeqCst);
Some(self.body.clone())
} else {
None
}
}
}
struct ObservationHookGuard;
impl Drop for ObservationHookGuard {
fn drop(&mut self) {
crate::object_api::unregister_get_object_body_cache_hook();
}
}
async fn ordinary_observation_get(set: &SetDisks, object: &str) -> Vec<u8> {
let mut reader = set
.get_object_reader(crate::disk::RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("ordinary GET positive control");
let mut bytes = Vec::new();
reader.stream.read_to_end(&mut bytes).await.expect("ordinary GET body");
bytes
}
fn observation_register_hook(object: &'static str, body: Vec<u8>) -> (ObservationHookGuard, Arc<AtomicU64>) {
let calls = Arc::new(AtomicU64::new(0));
crate::object_api::register_get_object_body_cache_hook(Arc::new(ObservationBodyHook {
object,
calls: calls.clone(),
body: bytes::Bytes::from(body),
}));
(ObservationHookGuard, calls)
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn locked_observation_uses_real_outer_guard() {
let object = "config/locked-observation-real.bin";
let payload = vec![b'p'; OBSERVATION_HOOK_BODY_LEN];
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(payload.clone());
let written = set
.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist locked observation fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; payload.len()]);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; payload.len()]);
assert_eq!(calls.swap(0, Ordering::SeqCst), 1);
let namespace = set
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, object)
.await
.expect("namespace");
let guard = crate::object_api::WriteCommitGuard::acquire(&namespace, std::time::Duration::from_secs(5))
.await
.expect("matching real owner");
let mut contender = Box::pin(crate::object_api::WriteCommitGuard::acquire(
&namespace,
std::time::Duration::from_secs(30),
));
assert!(futures::poll!(contender.as_mut()).is_pending(), "I06L_OWNER_RETAINED");
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
crate::config::com::read_config_observation_no_lock(set.clone(), object, payload.len(), &guard),
)
.await
.expect("locked observation hang watchdog");
assert!(result.is_ok(), "I06L_NO_NESTED_LOCK: {result:?}");
let (bytes, info) = result.expect("locked observation");
assert!(bytes == payload, "I06L_HOOK_BYPASS");
assert!(calls.load(Ordering::SeqCst) == 0, "I06L_HOOK_BYPASS");
assert_eq!(info.size, written.size);
assert_eq!(info.etag, written.etag);
assert_eq!(info.data_dir, written.data_dir);
assert_eq!(info.name, object);
assert!(futures::poll!(contender.as_mut()).is_pending(), "I06L_OWNER_RETAINED");
drop(guard);
let next = tokio::time::timeout(std::time::Duration::from_secs(10), contender)
.await
.expect("owner release watchdog")
.expect("competing owner acquired");
drop(next);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; payload.len()]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn locked_observation_preserves_empty_missing_and_bound() {
let (_dirs, set) = make_local_set_disks(4, 2).await;
for (object, body) in [
("config/locked-empty.bin", Vec::new()),
("config/locked-bound.bin", b"raw".to_vec()),
] {
let mut reader = PutObjReader::from_vec(body.clone());
let written = set
.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist boundary fixture");
let namespace = set
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, object)
.await
.expect("namespace");
let guard = crate::object_api::WriteCommitGuard::acquire(&namespace, std::time::Duration::from_secs(5))
.await
.expect("matching boundary owner");
let result = crate::config::com::read_config_observation_no_lock(set.clone(), object, body.len(), &guard).await;
assert!(result.is_ok(), "I06L_EMPTY_DISTINCT: {result:?}");
let (bytes, info) = result.expect("boundary observation");
assert_eq!(bytes, body);
assert_eq!(info.size, written.size);
if !body.is_empty() {
let result =
crate::config::com::read_config_observation_no_lock(set.clone(), object, body.len() - 1, &guard).await;
assert!(matches!(result, Err(Error::CorruptedFormat)), "I06L_BOUND_ENFORCED");
}
}
let object = "config/locked-absent.bin";
let namespace = set
.new_ns_lock(crate::disk::RUSTFS_META_BUCKET, object)
.await
.expect("namespace");
let guard = crate::object_api::WriteCommitGuard::acquire(&namespace, std::time::Duration::from_secs(5))
.await
.expect("matching missing owner");
let result = crate::config::com::read_config_observation_no_lock(set, object, 0, &guard).await;
assert!(matches!(result, Err(Error::ConfigNotFound)), "I06L_EMPTY_DISTINCT");
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_real_backend_boundaries() {
let object = "config/observation-real.bin";
let payload = b"persisted config";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(payload.to_vec());
let written = set
.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let result = crate::config::com::read_config_observation(set.clone(), object, payload.len()).await;
assert!(result.is_ok(), "I05_OBSERVATION_SUCCESS: {result:?}");
let (bytes, info) = result.expect("real observation success");
assert_eq!(bytes, payload);
assert_eq!(info.size, written.size);
assert_eq!(info.etag, written.etag);
assert_eq!(info.data_dir, written.data_dir);
assert_eq!(info.name, object);
let over = crate::config::com::read_config_observation(set.clone(), object, payload.len() - 1).await;
assert!(over.is_err(), "I05_BOUND_ENFORCED");
assert_eq!(over.expect_err("over limit must fail"), Error::CorruptedFormat, "I05_BOUND_ENFORCED");
let absent = crate::config::com::read_config_observation(set.clone(), "config/observation-absent.bin", 32).await;
assert_eq!(absent.expect_err("absent config"), Error::ConfigNotFound);
let empty_object = "config/observation-empty.bin";
let mut empty = PutObjReader::from_vec(Vec::new());
set.put_object(crate::disk::RUSTFS_META_BUCKET, empty_object, &mut empty, &ObjectOptions::default())
.await
.expect("persist empty config");
let empty = crate::config::com::read_config_observation(set, empty_object, 0).await;
assert!(empty.is_ok(), "existing empty config must succeed: {empty:?}");
let (bytes, info) = empty.expect("empty observation");
assert!(bytes.is_empty());
assert_eq!(info.size, 0);
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_bypasses_registered_body_hook() {
let object = "config/observation-hook.bin";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(vec![b'p'; OBSERVATION_HOOK_BODY_LEN]);
set.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.swap(0, Ordering::SeqCst), 1);
let result = crate::config::com::read_config_observation(set, object, OBSERVATION_HOOK_BODY_LEN).await;
assert!(result.is_ok(), "I05_HOOK_BYPASS: {result:?}");
assert_eq!(
result.expect("observed persisted body").0,
vec![b'p'; OBSERVATION_HOOK_BODY_LEN],
"I05_HOOK_BYPASS"
);
assert_eq!(calls.load(Ordering::SeqCst), 0, "I05_HOOK_BYPASS");
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_hook_scope_restores_after_error() {
let object = "config/observation-hook-error.bin";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(vec![b'p'; OBSERVATION_HOOK_BODY_LEN]);
set.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
let result = crate::config::com::read_config_observation(set.clone(), object, OBSERVATION_HOOK_BODY_LEN - 1).await;
assert_eq!(result.expect_err("bounded observation error"), Error::CorruptedFormat);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
struct PendingObservationStore {
set: Arc<SetDisks>,
ready: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
}
impl std::fmt::Debug for PendingObservationStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingObservationStore").finish()
}
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for PendingObservationStore {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
headers: HeaderMap,
opts: &ObjectOptions,
) -> Result<GetObjectReader> {
let mut reader = self.set.get_object_reader(bucket, object, range, headers, opts).await?;
let mut body = Vec::new();
reader.stream.read_to_end(&mut body).await.expect("pending producer real GET");
assert_eq!(
body,
vec![b'p'; OBSERVATION_HOOK_BODY_LEN],
"I05_HOOK_BYPASS: producer must enter its own scope"
);
self.ready
.lock()
.expect("pending signal")
.take()
.expect("one GET")
.send(())
.expect("producer entry signal");
std::future::pending::<Result<GetObjectReader>>().await
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut PutObjReader,
_opts: &ObjectOptions,
) -> Result<ObjectInfo> {
panic!("observation must not PUT");
}
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn observation_hook_scope_restores_after_cancel() {
let object = "config/observation-hook-cancel.bin";
let (_dirs, set) = make_local_set_disks(4, 2).await;
let mut reader = PutObjReader::from_vec(vec![b'p'; OBSERVATION_HOOK_BODY_LEN]);
set.put_object(crate::disk::RUSTFS_META_BUCKET, object, &mut reader, &ObjectOptions::default())
.await
.expect("persist real config fixture");
let (_hook, calls) = observation_register_hook(object, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.swap(0, Ordering::SeqCst), 1);
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let adapter = Arc::new(PendingObservationStore {
set: set.clone(),
ready: std::sync::Mutex::new(Some(ready_tx)),
});
let mut scope = Box::pin(crate::config::com::read_config_observation(adapter, object, OBSERVATION_HOOK_BODY_LEN));
tokio::select! {
result = ready_rx => { result.expect("producer reached pending GET"); }
result = &mut scope => { panic!("producer must remain pending until cancelled: {result:?}"); }
}
assert_eq!(calls.load(Ordering::SeqCst), 0);
drop(scope);
assert_eq!(ordinary_observation_get(&set, object).await, vec![b'x'; OBSERVATION_HOOK_BODY_LEN]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
const READ_VERSION_BARRIER_GUARD: std::time::Duration = std::time::Duration::from_secs(10);
fn object_with_initial_data_shards(bucket: &str, prefix: &str) -> String {
@@ -4772,6 +4466,7 @@ fn resolve_tiered_decommission_write_quorum_result(
pub struct SetDisks {
pub locker_owner: String,
pub disks: Arc<RwLock<Vec<Option<DiskStore>>>>,
pub(crate) physical_heal_admission: Arc<crate::disk::PhysicalHealBinding>,
pub set_endpoints: Vec<Endpoint>,
pub set_drive_count: usize,
pub default_parity_count: usize,
@@ -5733,6 +5428,7 @@ impl SetDisks {
Arc::new(SetDisks {
locker_owner,
disks,
physical_heal_admission: Arc::default(),
set_drive_count,
default_parity_count,
set_index,
@@ -14823,7 +14519,7 @@ mod tests {
file.erasure.index = files.len() + 1;
file.erasure.block_size = erasure.block_size;
file.uses_legacy_checksum = uses_legacy;
file.data = Some(Bytes::from(data));
file.data = Some(data);
files.push(file);
}
@@ -15284,7 +14980,7 @@ mod tests {
}
let encoded = bitrot_writer.into_inline_data().expect("bitrot encoded data should exist");
disk.write_all(bucket, &shard_path, Bytes::from(encoded))
disk.write_all(bucket, &shard_path, encoded)
.await
.expect("encoded shard should be stored");
@@ -15368,7 +15064,7 @@ mod tests {
}
let encoded = bitrot_writer.into_inline_data().expect("bitrot encoded data should exist");
disk.write_all(bucket, &shard_path, Bytes::from(encoded))
disk.write_all(bucket, &shard_path, encoded)
.await
.expect("encoded shard should be stored");
@@ -15481,7 +15177,7 @@ mod tests {
}
let encoded = bitrot_writer.into_inline_data().expect("bitrot encoded data should exist");
disk.write_all(bucket, &shard_path, Bytes::from(encoded))
disk.write_all(bucket, &shard_path, encoded)
.await
.expect("encoded shard should be stored");
}
@@ -16922,14 +16618,7 @@ mod tests {
#[tokio::test]
async fn set_level_versioned_delete_marker_hides_object_without_corrupting_version_metadata() {
let (_fixture, _, set_disks) = hermetic_set_disks_for_pool_with_default_parity(4, 0, 2).await;
set_disks.set_test_storage_class_config(
storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[4])
.expect("test storage class should resolve for the local drive count"),
);
let store = crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("fixture metadata authority should belong to this set");
let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await;
let bucket = "bucket-versioned-delete";
let object = "object.txt";
let opts = ObjectOptions {
@@ -16938,26 +16627,13 @@ mod tests {
..Default::default()
};
store
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"versioned object body".to_vec());
let written = set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.put_object(bucket, object, &mut reader, &opts)
.await
.expect("versioned object should be written");
assert!(written.version_id.is_some());
@@ -17274,14 +16950,7 @@ mod tests {
/// `BucketNotEmpty` for a bucket the client has already emptied.
#[tokio::test]
async fn set_level_explicit_null_version_delete_purges_the_null_delete_marker() {
let (_fixture, _, set_disks) = hermetic_set_disks_for_pool_with_default_parity(2, 0, 1).await;
set_disks.set_test_storage_class_config(
storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[2])
.expect("test storage class should resolve for the local drive count"),
);
let store = crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("fixture metadata authority should belong to this set");
let set_disks = make_local_bucket_test_set_disks().await;
let bucket = "bucket-null-marker-purge";
let object = "object.txt";
// The delete path reads versioned/suspended from the bucket-config
@@ -17303,26 +16972,13 @@ mod tests {
..Default::default()
};
store
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"suspended version body".to_vec());
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.put_object(bucket, object, &mut reader, &opts)
.await
.expect("suspended-version object should be written");
@@ -17347,20 +17003,7 @@ mod tests {
let mut replacement_reader = PutObjReader::from_vec(b"replacement suspended version body".to_vec());
set_disks
.put_object(
bucket,
object,
&mut replacement_reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.put_object(bucket, object, &mut replacement_reader, &opts)
.await
.expect("a suspended-version PUT should replace the first null marker");
let replacement_marker = set_disks
@@ -17420,14 +17063,7 @@ mod tests {
#[tokio::test]
async fn set_level_version_suspended_delete_creates_null_delete_marker() {
let (_fixture, _, set_disks) = hermetic_set_disks_for_pool_with_default_parity(2, 0, 1).await;
set_disks.set_test_storage_class_config(
storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[2])
.expect("test storage class should resolve for the local drive count"),
);
let store = crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("fixture metadata authority should belong to this set");
let set_disks = make_local_bucket_test_set_disks().await;
let bucket = "bucket-version-suspended-delete";
let object = "object.txt";
let opts = ObjectOptions {
@@ -17437,26 +17073,13 @@ mod tests {
..Default::default()
};
store
set_disks
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
let mut reader = PutObjReader::from_vec(b"suspended version body".to_vec());
set_disks
.put_object(
bucket,
object,
&mut reader,
&ObjectOptions {
object_lock_config_snapshot: Some(
store
.object_lock_config_snapshot(bucket)
.await
.expect("PUT fixture should own the bucket lifecycle and Object Lock snapshot"),
),
..opts.clone()
},
)
.put_object(bucket, object, &mut reader, &opts)
.await
.expect("suspended-version object should be written");
@@ -113,7 +113,6 @@ pub(crate) struct DeleteRecoveryContext<'a> {
pub repair_marker_source: bool,
pub namespace_owner: Option<Arc<dyn Send + Sync>>,
pub recovery_ticket: Option<crate::store::delete_recovery_runtime::DeleteRecoveryTicket>,
pub published_hint: Option<(Uuid, &'a [Result<DeleteRecoveryInspection>])>,
pub can_commit: &'a (dyn Fn() -> bool + Sync),
}
@@ -126,7 +125,6 @@ impl<'a> DeleteRecoveryContext<'a> {
repair_marker_source: false,
namespace_owner: None,
recovery_ticket: None,
published_hint: None,
can_commit,
}
}
@@ -423,9 +421,7 @@ impl SetDisks {
let cleanup = loop {
#[cfg(test)]
test_hooks::record_cleanup_attempt(bucket, object);
let mut attempt_context = context.clone();
attempt_context.published_hint = (attempt == 1).then_some((transaction, published.as_slice()));
match self.recover_delete_key(bucket, object, attempt_context).await {
match self.recover_delete_key(bucket, object, context.clone()).await {
// A stale settlement fingerprint only means the snapshot raced
// another writer; each attempt re-inspects every participant.
Err(DiskError::OutdatedXLMeta) if attempt < PUBLISHED_CLEANUP_ATTEMPTS && can_commit() => attempt += 1,
@@ -555,7 +551,6 @@ impl SetDisks {
explicit_delete,
repair_marker_source,
namespace_owner,
published_hint,
can_commit,
recovery_ticket: _,
} = context;
@@ -594,18 +589,7 @@ impl SetDisks {
let mut all_states = BTreeMap::new();
for transaction in transactions {
let mut complete = Vec::with_capacity(disks.len());
let inspections = if let Some((published_tx, published_states)) = published_hint
&& !repair_marker_source
&& !dry_run
&& published_tx == transaction
&& published_states.len() == disks.len()
&& published_states.iter().all(Result::is_ok)
{
published_states.to_vec()
} else {
inspect(&disks, bucket, object, transaction).await
};
for state in inspections {
for state in inspect(&disks, bucket, object, transaction).await {
match state {
Ok(state) => {
if let Some(journal) = &state.journal {
@@ -848,9 +832,6 @@ impl SetDisks {
bucket,
copies,
|copy| {
if receipt(copy.state) && copy.commit_data_safe {
return None;
}
Some(
if copy.state == DeleteRecoveryState::Absent
|| (certified_same_transaction
@@ -876,10 +857,7 @@ impl SetDisks {
&disks,
bucket,
copies,
|copy| {
(copy.state != DeleteRecoveryState::CommitFinished || !copy.commit_data_safe)
.then_some(DeleteRecoveryAction::FinishCommit)
},
|_| Some(DeleteRecoveryAction::FinishCommit),
can_commit,
namespace_owner.as_ref(),
&mut summary.removed_versions,
@@ -944,25 +922,13 @@ impl SetDisks {
// GC is a key-wide barrier: every predecessor must have settled
// before the last surviving commit witness can disappear.
let mut settled = Vec::with_capacity(all_states.len());
if all_states.len() == 1 {
let copies = all_states.into_values().next().ok_or(DiskError::FileCorrupt)?;
if copies
.iter()
.any(|state| !matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))
{
for transaction in all_states.keys() {
let fresh = inspect(&disks, bucket, object, *transaction).await;
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
summary.pending += 1;
return Ok(summary);
}
settled.push(copies);
} else {
for transaction in all_states.keys() {
let fresh = inspect(&disks, bucket, object, *transaction).await;
if fresh.iter().any(|state| !matches!(state, Ok(state) if matches!(state.state, DeleteRecoveryState::Absent | DeleteRecoveryState::CommitFinished))) {
summary.pending += 1;
return Ok(summary);
}
settled.push(fresh.into_iter().collect::<Result<Vec<_>>>()?);
}
settled.push(fresh.into_iter().collect::<Result<Vec<_>>>()?);
}
for (index, mut copies) in settled.into_iter().enumerate() {
if index > 0 {
File diff suppressed because it is too large Load Diff
@@ -15,9 +15,7 @@
use crate::disk::{DiskAPI as _, ReadOptions};
use crate::object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode};
use crate::set_disk::CompletePart;
use crate::set_disk::ops::object::hermetic_set_disks_support::{
hermetic_set_disks_for_pool_with_default_parity_isolated, lifecycle_owned_put_options,
};
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity_isolated;
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
@@ -41,19 +39,18 @@ async fn remote_metadata_heal_does_not_certify_unread_tier_payload() {
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = lifecycle_owned_put_options(
&set,
set.put_object(
bucket,
ObjectOptions {
object,
&mut PutObjReader::from_vec(vec![0x31; 1024 * 1024 + 17]),
&ObjectOptions {
no_lock: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Protected),
..Default::default()
},
)
.await;
set.put_object(bucket, object, &mut PutObjReader::from_vec(vec![0x31; 1024 * 1024 + 17]), &opts)
.await
.expect("protected source");
.await
.expect("protected source");
for (disk, dir) in disks.iter().zip(&dirs) {
let mut info = disk
.read_version("", bucket, object, "", &ReadOptions::default())
@@ -97,15 +94,10 @@ async fn rollout_requires_both_flags_for_new_puts() {
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await;
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
for (requested, confirmed, protected) in [
(false, false, false),
@@ -167,17 +159,12 @@ async fn legacy_shard_repair_and_explicit_version_metadata_recovery_remain_avail
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
versioned: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
..Default::default()
},
)
.await;
let opts = ObjectOptions {
no_lock: true,
versioned: true,
shard_integrity_write_mode: Some(ShardIntegrityWriteMode::Legacy),
..Default::default()
};
let expected = vec![0x67; 1024 * 1024 + 37];
for case in ["missing-part", "bad-bitrot", "missing-version-metadata"] {
let object = case;
@@ -285,15 +272,10 @@ async fn multipart_mode_survives_rollout_switch_changes() {
for disk in &disks {
disk.make_volume(bucket).await.expect("fixture bucket");
}
let opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await;
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
for protected in [false, true] {
let object = format!("upload-{protected}");
let upload =
@@ -372,16 +354,11 @@ async fn physical_copy_inherits_source_mode_instead_of_current_switch() {
};
let object = format!("source-{protected}");
let expected = vec![0x39; 1024 * 1024 + 11];
let seed_opts = lifecycle_owned_put_options(
&set,
bucket,
ObjectOptions {
no_lock: true,
shard_integrity_write_mode: Some(mode),
..Default::default()
},
)
.await;
let seed_opts = ObjectOptions {
no_lock: true,
shard_integrity_write_mode: Some(mode),
..Default::default()
};
let mut source = set
.put_object(bucket, &object, &mut PutObjReader::from_vec(expected.clone()), &seed_opts)
.await
@@ -389,8 +366,8 @@ async fn physical_copy_inherits_source_mode_instead_of_current_switch() {
source.metadata_only = false;
source.put_object_reader = Some(PutObjReader::from_vec(expected.clone()));
let opts = ObjectOptions {
shard_integrity_write_mode: None,
..seed_opts.clone()
no_lock: true,
..Default::default()
};
let copied = temp_env::async_with_vars(
rollout_vars(!protected, !protected),
+53 -551
View File
@@ -2247,9 +2247,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
// lifetime; a fence by itself cannot transfer the outer lock lease.
let detach_commit_owner = _upload_commit_guard.is_some()
&& opts.namespace_lock_fence.is_none()
&& (opts.bucket_lifecycle_lock_fence.is_none()
|| opts.bucket_lifecycle_lock_owner.is_some()
|| opts.bucket_incarnation_fence.is_some());
&& (opts.bucket_lifecycle_lock_fence.is_none() || opts.bucket_lifecycle_lock_owner.is_some());
let commit_set = self.clone();
let commit_bucket = bucket.to_owned();
let commit_object = object.to_owned();
@@ -3904,7 +3902,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
crate::set_disk::core::io_primitives::RenameDataFenceOptions::new(write_quorum, None)
.with_namespace_commit_guard(
(!crate::bucket::utils::is_meta_bucketname(&commit_bucket))
.then(|| commit_set.ctx.begin_namespace_commit() as Arc<dyn Send + Sync>),
.then(|| commit_set.ctx.begin_namespace_commit()),
),
)
.await;
@@ -4181,12 +4179,14 @@ mod tests {
use crate::services::notification_sys::install_remote_version_state_fleet_proof_for_test;
use crate::set_disk::core::io_primitives::{ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, rename_fanout_barrier};
use crate::set_disk::get_lock_acquire_timeout;
// All fixtures own isolated metadata contexts; lock-failure tests switch
// the same context to distributed mode after seeding their objects.
// No-locker helpers resolve to the isolated-context variants (see
// `hermetic_set_disks_isolated`); the guard-based tests build through
// `hermetic_set_disks_with_lockers`, which stays on the bootstrap context
// so their own ambient SetupTypeGuard flip remains visible to them.
use crate::set_disk::ops::object::hermetic_set_disks_support::{
hermetic_set_disks_for_pool_with_default_parity_isolated as hermetic_set_disks_for_pool_with_default_parity,
hermetic_set_disks_isolated as hermetic_set_disks, hermetic_set_disks_with_lockers,
hermetic_set_disks_with_lockers_and_ctx, lifecycle_owned_put_options,
hermetic_set_disks_with_lockers_and_ctx,
};
use crate::set_disk::ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
@@ -4408,33 +4408,24 @@ mod tests {
}
struct SetupTypeGuard {
ctx: Arc<crate::runtime::instance::InstanceContext>,
previous: SetupType,
}
impl SetupTypeGuard {
async fn switch_to(ctx: &Arc<crate::runtime::instance::InstanceContext>, next: SetupType) -> Self {
let previous = if ctx.is_dist_erasure().await {
SetupType::DistErasure
} else {
SetupType::Erasure
};
ctx.update_erasure_type(next).await;
Self {
ctx: Arc::clone(ctx),
previous,
}
async fn switch_to(next: SetupType) -> Self {
let previous = crate::runtime::sources::current_setup_type().await;
crate::runtime::sources::set_setup_type(next).await;
Self { previous }
}
}
impl Drop for SetupTypeGuard {
fn drop(&mut self) {
let previous = self.previous.clone();
let ctx = Arc::clone(&self.ctx);
let handle = tokio::runtime::Handle::current();
std::thread::spawn(move || {
handle.block_on(async {
ctx.update_erasure_type(previous).await;
crate::runtime::sources::set_setup_type(previous).await;
});
})
.join()
@@ -5406,15 +5397,10 @@ mod tests {
bucket,
object,
&mut initial_reader,
&lifecycle_owned_put_options(
&set_disks,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("initial fenced PUT should commit");
@@ -5453,15 +5439,10 @@ mod tests {
bucket,
object,
&mut winner_reader,
&lifecycle_owned_put_options(
&set_disks,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("concurrent fenced PUT should advance the epoch");
@@ -6204,22 +6185,15 @@ mod tests {
}
async fn assert_complete_first_linearizes(bucket: &'static str, object: &'static str, create_opts: ObjectOptions) {
use crate::storage_api_contracts::bucket::BucketOperations as _;
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
let lockers: Vec<Arc<dyn LockClient>> = vec![signaling.clone()];
let (_temp_dirs, _disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("multipart fixture metadata owner")
.make_bucket(bucket, &Default::default())
.await
.expect("multipart fixture bucket has authoritative quota metadata");
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
make_bucket_on_all(&disk_stores, bucket).await;
let (upload_id, parts) = stage_upload_with_create_opts(&set_disks, bucket, object, &[0x47; 4096], &create_opts).await;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterRename);
let complete_store = set_disks.clone();
@@ -6262,22 +6236,15 @@ mod tests {
}
async fn assert_abort_first_linearizes(bucket: &'static str, object: &'static str, create_opts: ObjectOptions) {
use crate::storage_api_contracts::bucket::BucketOperations as _;
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
let lockers: Vec<Arc<dyn LockClient>> = vec![signaling.clone()];
let (_temp_dirs, _disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("multipart fixture metadata owner")
.make_bucket(bucket, &Default::default())
.await
.expect("multipart fixture bucket has authoritative quota metadata");
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
make_bucket_on_all(&disk_stores, bucket).await;
let (upload_id, parts) = stage_upload_with_create_opts(&set_disks, bucket, object, &[0x48; 4096], &create_opts).await;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let object_holder = set_disks
.new_ns_lock(bucket, object)
.await
@@ -6333,365 +6300,17 @@ mod tests {
));
}
#[cfg(not(windows))]
async fn assert_automatic_part_rollback(settlement_timeout: bool) {
use crate::disk::{
Disk,
disk_store::LocalDiskWrapper,
health_state::RuntimeDriveHealthState,
os::{drain_namespace_for_test, prepared_publication_test_hooks as hooks},
};
use crate::io_support::shard_integrity::IntegrityBuilder;
use crate::storage_api_contracts::bucket::BucketOperations as _;
use rustfs_filemeta::shard_integrity::IntegrityLayout;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::{Duration, timeout};
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("5")),
(crate::disk::disk_store::ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, Some("true")),
],
async {
for old in [false, true] {
let (_roots, original_disks, set_disks) = hermetic_set_disks_for_pool_with_default_parity(4, 0, 2).await;
// This fixture normally disables health monitors. Install ordinary
// monitored wrappers before any scenario I/O, on the same four disks.
let disks = original_disks
.iter()
.map(|disk| match disk.as_ref() {
Disk::Local(local) => Arc::new(Disk::Local(Box::new(LocalDiskWrapper::new(local.get_disk(), true)))),
Disk::Remote(_) => unreachable!("four local disks"),
})
.collect::<Vec<_>>();
*set_disks.disks.write().await = disks.iter().cloned().map(Some).collect();
assert!(disks.iter().all(|disk| disk.health_check_enabled_for_test()));
let bucket = "part-tail-rollback";
let object = "object";
crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("multipart fixture metadata owner")
.make_bucket(bucket, &Default::default())
.await
.expect("multipart fixture bucket has authoritative quota metadata");
let (destination, completion) = if old {
let payload = vec![0x41; 1 << 20];
let create_opts = ObjectOptions {
shard_integrity_write_mode: Some(crate::object_api::ShardIntegrityWriteMode::Protected),
..Default::default()
};
let (upload_id, parts) =
stage_upload_with_create_opts(&set_disks, bucket, object, &payload, &create_opts).await;
let (info, _) = set_disks
.check_upload_id_exists(bucket, object, &upload_id, false)
.await
.expect("acknowledged upload");
(
format!(
"{}/{}/part.1",
SetDisks::get_upload_id_dir(bucket, object, &upload_id),
info.data_dir.expect("data directory")
),
Some((upload_id, parts, payload)),
)
} else {
(format!("{}/part.1", Uuid::new_v4()), None)
};
let source = format!("{}/part.1", Uuid::new_v4());
let mut builder =
IntegrityBuilder::new(IntegrityLayout::new(2, 2, 8, false).expect("layout"), 1).expect("index builder");
builder.push([b"bbbb".as_slice(); 4].into_iter()).await.expect("stripe");
let proof = builder.finish(8).await.expect("proof");
let index = proof.inline_bytes().expect("inline index");
let proof_name = proof.part.file_name();
let meta = Bytes::from(
ObjectPartInfo {
number: 1,
size: 8,
integrity: Some(proof.part),
etag: "tail-retry".to_owned(),
..Default::default()
}
.marshal_msg()
.expect("metadata"),
);
let mut snapshots = Vec::new();
let mut paths = Vec::new();
for disk in &disks {
let target = disk
.get_object_path_for_io_if_local(RUSTFS_META_MULTIPART_BUCKET, &destination)
.expect("local disk")
.expect("target");
let old_data = std::fs::read(&target).ok();
let old_meta = std::fs::read(target.with_extension("1.meta")).ok();
let old_index = old_meta
.as_ref()
.and_then(|bytes| ObjectPartInfo::unmarshal(bytes).ok())
.and_then(|part| part.integrity)
.map(|integrity| {
let path = target.parent().expect("parent").join(integrity.file_name());
let bytes = std::fs::read(&path).expect("acknowledged index");
(path, bytes)
});
if old {
assert!(old_index.is_some(), "acknowledged protected upload must have an integrity index");
}
snapshots.push((old_data, old_meta, old_index));
paths.push(target);
disk.write_all(RUSTFS_META_TMP_BUCKET, &source, Bytes::from_static(b"bbbb"))
.await
.expect("retry shard");
disk.write_all(
RUSTFS_META_TMP_BUCKET,
&format!("{}/{}", source.rsplit_once('/').expect("directory").0, proof_name),
index.clone(),
)
.await
.expect("retry index");
}
let mut guards = Vec::new();
let mut releases = Vec::new();
let mut entered = Vec::new();
let mut settle_waiting = Vec::new();
let mut settle_completed = Vec::new();
let mut settle_timed_out = Vec::new();
let mut restored = Vec::new();
let mut finished = Vec::new();
// Two of four publications cannot acknowledge the required quorum of three.
for (number, target) in paths.iter().take(2).enumerate() {
let paused_path = if number == 0 {
target.clone()
} else {
target.with_extension("1.meta")
};
let transaction = target.parent().expect("parent").join(".part.1.rustfs-txn");
let (tx, rx) = tokio::sync::oneshot::channel();
let (release, blocked) = std::sync::mpsc::channel::<()>();
guards.push(hooks::install_at(hooks::Stage::Rename, &paused_path, move || {
let _ = tx.send(());
let _ = blocked.recv();
}));
entered.push(rx);
releases.push(release);
let (tx, rx) = tokio::sync::oneshot::channel();
guards.push(hooks::install_at(hooks::Stage::NamespaceWaiting, target, move || {
let _ = tx.send(());
}));
settle_waiting.push(rx);
let (tx, rx) = tokio::sync::oneshot::channel();
guards.push(hooks::install_at(hooks::Stage::PartSettleTimeout, target, move || {
let _ = tx.send(());
}));
settle_timed_out.push(rx);
let restore = Arc::new(AtomicBool::new(false));
let observed = restore.clone();
guards.push(hooks::install_at(hooks::Stage::PartRestore, &transaction, move || {
observed.store(true, Ordering::SeqCst);
}));
restored.push(restore);
let complete = Arc::new(AtomicBool::new(false));
let observed = complete.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
guards.push(hooks::install_at(hooks::Stage::PartSettleComplete, target, move || {
observed.store(true, Ordering::SeqCst);
let _ = tx.send(());
}));
settle_completed.push(rx);
finished.push(complete);
}
let operation_set = set_disks.clone();
let operation_disks = disks.iter().cloned().map(Some).collect::<Vec<_>>();
let operation_source = source.clone();
let operation_destination = destination.clone();
let operation = tokio::spawn(async move {
operation_set
.rename_part(
&operation_disks,
RUSTFS_META_TMP_BUCKET,
&operation_source,
RUSTFS_META_MULTIPART_BUCKET,
&operation_destination,
meta,
3,
None,
)
.await
});
let mut waited = Vec::new();
let mut before_drain = Vec::new();
let mut after_timeout = Vec::new();
// Keep observations until both physical workers have drained. Even a
// deliberately broken admission path must not strand a test worker.
let observed = async {
for receiver in entered {
timeout(Duration::from_secs(30), receiver)
.await
.map_err(|_| "publisher did not reach barrier")?
.map_err(|_| "publisher hook dropped")?;
}
for (waiting, completed) in settle_waiting.into_iter().zip(settle_completed) {
let pending = timeout(Duration::from_secs(15), async {
tokio::select! {
result = waiting => result.map(|()| true),
result = completed => result.map(|()| false),
}
})
.await
.map_err(|_| "automatic settlement neither waited nor completed")?
.map_err(|_| "settlement observation dropped")?;
waited.push(pending);
}
for (number, target) in paths.iter().take(2).enumerate() {
let transaction = target.parent().expect("parent").join(".part.1.rustfs-txn");
before_drain.push((
restored[number].load(Ordering::SeqCst),
finished[number].load(Ordering::SeqCst),
transaction.join("new.meta").exists(),
transaction.join("rollback").exists(),
std::fs::read(target.with_extension("1.meta")).ok(),
));
}
if settlement_timeout && waited.iter().all(|pending| *pending) {
for receiver in settle_timed_out {
timeout(Duration::from_secs(15), receiver)
.await
.map_err(|_| "settlement did not report Timeout")?
.map_err(|_| "timeout observation dropped")?;
}
for (number, target) in paths.iter().take(2).enumerate() {
let transaction = target.parent().expect("parent").join(".part.1.rustfs-txn");
after_timeout.push((
transaction.join("new.meta").exists(),
transaction.join("rollback").exists(),
restored[number].load(Ordering::SeqCst),
));
}
}
Ok::<(), &str>(())
}
.await;
drop(releases);
let mut drained = Vec::new();
for target in paths.iter().take(2) {
drained.push(timeout(Duration::from_secs(15), drain_namespace_for_test(target)).await);
}
let result = timeout(Duration::from_secs(30), operation).await;
for result in drained {
result.expect("actual publisher namespace drains");
}
observed.expect("observe automatic rollback under real deadlines");
assert_eq!(waited, [true, true], "automatic rollback must really wait for the publisher's D lock");
for (number, (restored, finished, has_meta, has_rollback, meta)) in before_drain.iter().enumerate() {
assert!(!restored, "automatic rollback must not restore before the old publisher drains");
assert!(!finished, "automatic rollback must wait for D, not return a competing-namespace error");
assert!(*has_meta);
assert!(!has_rollback, "no rollback write precedes physical drain");
assert_eq!(meta, &snapshots[number].1);
}
if settlement_timeout {
assert_eq!(after_timeout.len(), 2);
for (has_meta, has_rollback, restored) in after_timeout {
assert!(has_meta, "timed-out settlement retains recovery input");
assert!(!has_rollback && !restored);
}
}
let result = result.expect("automatic recovery returns").expect("quorum task");
assert!(
matches!(result, Err(DiskError::ErasureWriteQuorum)),
"publication preserves its original quorum failure: {result:?}"
);
if settlement_timeout {
// Two consecutive real timeouts take these drives offline. Only
// production health probes may make them available to recovery.
timeout(Duration::from_secs(30), async {
let mut poll = tokio::time::interval(Duration::from_millis(25));
while disks
.iter()
.any(|disk| disk.runtime_state() != RuntimeDriveHealthState::Online)
{
poll.tick().await;
}
})
.await
.expect("real recovery monitors restore all four disks");
let directory = format!("{}/", destination.rsplit_once('/').expect("directory").0);
set_disks
.recover_part_transactions(&directory, 2, 3)
.await
.expect("existing recovery converges after monitor recovery");
}
// The ordinary case reaches these assertions directly from the
// real rename_part return, with no supplemental recovery call.
for (number, target) in paths.iter().enumerate() {
assert_eq!(std::fs::read(target).ok(), snapshots[number].0, "data on disk {number}");
assert_eq!(
std::fs::read(target.with_extension("1.meta")).ok(),
snapshots[number].1,
"metadata on disk {number}"
);
if let Some((path, bytes)) = &snapshots[number].2 {
assert_eq!(&std::fs::read(path).expect("old index survives"), bytes);
}
assert!(!target.parent().expect("parent").join(".part.1.rustfs-txn").exists());
}
if let Some((upload_id, parts, payload)) = completion {
use tokio::io::AsyncReadExt as _;
set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &ObjectOptions::default())
.await
.expect("acknowledged old upload stays completable");
let mut reader = set_disks
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
.await
.expect("completed object");
let mut bytes = Vec::new();
reader.stream.read_to_end(&mut bytes).await.expect("read restored object");
assert_eq!(bytes, payload);
}
drop(guards);
for disk in &disks {
if let Disk::Local(local) = disk.as_ref() {
local.stop_monitoring().await;
}
}
}
},
)
.await;
}
#[cfg(not(windows))]
#[tokio::test]
#[serial_test::serial(capacity_dirty_scope)]
async fn legacy_part_mutation_flow_automatic_quorum_rollback_waits_for_tail() {
assert_automatic_part_rollback(false).await;
}
#[cfg(not(windows))]
#[tokio::test]
#[serial_test::serial(capacity_dirty_scope)]
async fn legacy_part_mutation_flow_settlement_timeout_retains_recoverable_state() {
assert_automatic_part_rollback(true).await;
}
async fn assert_quorum_minus_one_retry_preserves_completable_part(
disk_count: usize,
parity: usize,
success_indices: &[usize],
) {
use crate::storage_api_contracts::bucket::BucketOperations as _;
use tokio::io::AsyncReadExt as _;
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_for_pool_with_default_parity(disk_count, 0, parity).await;
let bucket = format!("multipart-retry-{disk_count}-{}", success_indices[0]);
let object = "object";
crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("multipart fixture metadata owner")
.make_bucket(&bucket, &Default::default())
.await
.expect("multipart fixture bucket has authoritative quota metadata");
make_bucket_on_all(&disk_stores, &bucket).await;
let payload = vec![0x41; 1 << 20];
let (upload_id, parts) =
@@ -6870,18 +6489,12 @@ mod tests {
#[tokio::test]
async fn complete_multipart_upload_recovers_interrupted_part_retry() {
use crate::storage_api_contracts::bucket::BucketOperations as _;
use tokio::io::AsyncReadExt as _;
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_for_pool_with_default_parity(6, 0, 2).await;
let bucket = "multipart-retry-recovery";
let object = "object";
crate::bucket::metadata_sys::object_store_in(&set_disks.ctx)
.await
.expect("multipart fixture metadata owner")
.make_bucket(bucket, &Default::default())
.await
.expect("multipart fixture bucket has authoritative quota metadata");
make_bucket_on_all(&disk_stores, bucket).await;
let payload = vec![0x42; 1 << 20];
let (upload_id, parts) =
@@ -7041,15 +6654,10 @@ mod tests {
bucket,
object,
&mut initial_reader,
&lifecycle_owned_put_options(
&set_disks,
bucket,
ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
.expect("initial object should be written");
@@ -7460,7 +7068,7 @@ mod tests {
let upload_id = upload.upload_id;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let holder = set_disks
.new_ns_lock(RUSTFS_META_MULTIPART_BUCKET, &upload_id_path)
.await
@@ -7496,7 +7104,7 @@ mod tests {
set_disks.check_upload_id_exists(bucket, object, &upload_id, false).await,
Err(StorageError::InvalidUploadID(..))
));
for temp_dir in &temp_dirs {
for temp_dir in temp_dirs {
assert!(
!temp_dir
.path()
@@ -7525,7 +7133,7 @@ mod tests {
let upload_id = upload.upload_id;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::PutPartAfterRename);
let put_store = set_disks.clone();
@@ -7562,11 +7170,9 @@ mod tests {
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn cancelled_put_object_part_keeps_upload_lock_through_commit() {
for (pause, typed_owner) in [
(MultipartCommitPause::PutPartAfterCapacityAdmission, false),
(MultipartCommitPause::PutPartAfterCapacityAdmission, true),
(MultipartCommitPause::PutPartAfterRename, false),
(MultipartCommitPause::PutPartAfterRename, true),
for pause in [
MultipartCommitPause::PutPartAfterCapacityAdmission,
MultipartCommitPause::PutPartAfterRename,
] {
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
@@ -7582,18 +7188,7 @@ mod tests {
let upload_id = upload.upload_id;
let upload_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let put_opts = if typed_owner {
lifecycle_owned_put_options(&set_disks, bucket, ObjectOptions::default()).await
} else {
ObjectOptions::default()
};
let lifecycle_owner = put_opts.bucket_incarnation_fence.as_ref().map(Arc::downgrade);
if typed_owner {
assert!(put_opts.bucket_lifecycle_lock_fence.is_some());
assert!(put_opts.bucket_lifecycle_lock_owner.is_none());
assert!(lifecycle_owner.is_some());
}
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, pause);
let put_store = set_disks.clone();
@@ -7601,7 +7196,7 @@ mod tests {
let put = tokio::spawn(async move {
let mut reader = PutObjReader::from_vec(vec![0x45; 4096]);
put_store
.put_object_part(bucket, object, &put_upload_id, 1, &mut reader, &put_opts)
.put_object_part(bucket, object, &put_upload_id, 1, &mut reader, &ObjectOptions::default())
.await
});
barrier.wait_until_paused().await;
@@ -7612,13 +7207,6 @@ mod tests {
.is_cancelled()
);
if let Some(owner) = lifecycle_owner {
assert!(
owner.upgrade().is_some(),
"cancelled caller must not release the commit's typed lifecycle owner"
);
}
let abort_store = set_disks.clone();
let abort_upload_id = upload_id.clone();
let mut abort = tokio::spawn(async move {
@@ -7668,7 +7256,7 @@ mod tests {
.await
.expect("multipart upload should be created");
let upload_id = upload.upload_id;
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
// issue#5961: the barrier releases only once BOTH commits are paused
// inside their commit sections, so reaching wait_until_paused proves the
// two part numbers held their commit locks concurrently. Under an
@@ -7764,7 +7352,7 @@ mod tests {
let upload_id = upload.upload_id;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
let part_lock_path = format!("{upload_id_path}/part.1");
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::PutPartAfterRename);
let first_store = set_disks.clone();
@@ -7873,7 +7461,7 @@ mod tests {
let part_lock_path = format!("{upload_id_path}/part.1");
*target.write().expect("lock-loss target should be writable") =
Some(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, part_lock_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::PutPartBeforeLockLost);
let put_store = set_disks.clone();
@@ -7941,7 +7529,7 @@ mod tests {
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
*target.write().expect("lock-loss target should be writable") =
Some(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::PutPartBeforeLockLost);
let put_store = set_disks.clone();
@@ -8186,92 +7774,6 @@ mod tests {
.await;
}
#[tokio::test]
async fn legacy_path_delete_flow_multipart_cleanup_preserves_order_and_quorum() {
use crate::storage_api_contracts::bucket::BucketOperations;
let (_dirs, disks, set) = hermetic_set_disks(4).await;
for disk in &disks {
for path in ["cleanup/first", "cleanup/blocker", "cleanup/last"] {
disk.write_all(RUSTFS_META_MULTIPART_BUCKET, path, Bytes::from_static(b"staged"))
.await
.expect("seed raw multipart file");
}
}
set.cleanup_multipart_path(&[
"cleanup/first".to_owned(),
"cleanup/blocker/child".to_owned(),
"cleanup/last".to_owned(),
])
.await;
for disk in &disks {
assert!(
matches!(
disk.read_all(RUSTFS_META_MULTIPART_BUCKET, "cleanup/first").await,
Err(DiskError::FileNotFound)
),
"batch keeps the successful item before its first error"
);
for path in ["cleanup/blocker", "cleanup/last"] {
assert_eq!(
disk.read_all(RUSTFS_META_MULTIPART_BUCKET, path)
.await
.expect("later file remains"),
b"staged".as_slice(),
"batch must stop at the first non-directory error"
);
}
}
set.cleanup_multipart_path(&["cleanup/blocker".to_owned(), "cleanup/last".to_owned()])
.await;
for disk in &disks {
for path in ["cleanup/blocker", "cleanup/last"] {
assert!(
matches!(disk.read_all(RUSTFS_META_MULTIPART_BUCKET, path).await, Err(DiskError::FileNotFound)),
"retry cleans remaining raw files on every disk"
);
}
}
let bucket = "path-delete-multipart";
crate::bucket::metadata_sys::object_store_in(&set.ctx)
.await
.expect("fixture object store")
.make_bucket(bucket, &Default::default())
.await
.expect("authoritative multipart bucket");
for available in [3, 2] {
let object = format!("object-{available}");
let upload = set
.new_multipart_upload(bucket, &object, &ObjectOptions::default())
.await
.expect("create upload before reducing availability");
let saved = {
let mut current = set.disks.write().await;
let saved = current.clone();
for disk in current.iter_mut().skip(available) {
*disk = None;
}
saved
};
let result = set
.abort_multipart_upload(bucket, &object, &upload.upload_id, &ObjectOptions::default())
.await;
*set.disks.write().await = saved;
let remaining = set.check_upload_id_exists(bucket, &object, &upload.upload_id, false).await;
if available == 3 {
result.expect("three of four disks satisfy delete write quorum");
assert!(matches!(remaining, Err(StorageError::InvalidUploadID(..))));
} else {
assert!(
matches!(result, Err(StorageError::ErasureWriteQuorum)),
"two disks must not acknowledge an abort"
);
remaining.expect("failed abort leaves quorum-visible staging after disks return");
}
}
}
#[tokio::test]
async fn multipart_metadata_io_does_not_block_disk_registry_updates() {
use crate::set_disk::core::io_primitives::rename_fanout_barrier::{self, PHASE_READ_VERSION};
@@ -8438,7 +7940,7 @@ mod tests {
let (upload_id, parts) = stage_upload_with_create_opts(&set_disks, bucket, object, &[0x42; 4096], &create_opts).await;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let holder = set_disks
.new_ns_lock(RUSTFS_META_MULTIPART_BUCKET, &upload_id_path)
.await
@@ -8523,7 +8025,7 @@ mod tests {
let object_resource = rustfs_lock::ObjectKey::new(bucket, object);
signaling.set_target(upload_resource.clone());
signaling.clear_observed();
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterRename);
let complete_store = set_disks.clone();
@@ -8663,7 +8165,7 @@ mod tests {
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path));
signaling.clear_observed();
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterRename);
let complete_store = set_disks.clone();
@@ -8768,7 +8270,7 @@ mod tests {
use futures::FutureExt;
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("60"))], async {
let (mut dirs, disks, set_disks) = hermetic_set_disks(4).await;
let (dirs, disks, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-physical-namespace";
let object = if undo { "undo-tail" } else { "publication-tail" };
let old_body = vec![0x41; 1024];
@@ -8938,7 +8440,7 @@ mod tests {
if !coordinator_drained || !physical_drained || owner_drained.is_err() {
// A bounded cleanup failure cannot justify deleting roots that a
// detached executor might still use. Keep them for diagnosis.
let retained = dirs.keep_dirs();
let retained = dirs.into_iter().map(TempDir::keep).collect::<Vec<_>>();
eprintln!("MPU cleanup incomplete: coordinator={coordinator_drained}, physical={physical_drained}, retained={retained:?}");
if let Err(panic) = observations {
std::panic::resume_unwind(panic);
@@ -9109,7 +8611,7 @@ mod tests {
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path));
signaling.clear_observed();
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let completion_barrier =
MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterObjectPublication);
@@ -9220,7 +8722,7 @@ mod tests {
make_bucket_on_all(&disk_stores, bucket).await;
let (upload_id, parts) =
stage_upload_with_create_opts(&set_disks, bucket, object, &[0x65; 4096], &ObjectOptions::default()).await;
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let completion_barrier =
MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::BeforeObjectPublication);
@@ -9276,7 +8778,7 @@ mod tests {
stage_upload_with_create_opts(&set_disks, bucket, object, &[0x49; 4096], &ObjectOptions::default()).await;
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
signaling.set_target(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::PutPartBeforeLockLost);
let put_store = set_disks.clone();
@@ -9343,7 +8845,7 @@ mod tests {
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
*target.write().expect("lock-loss target should be writable") =
Some(rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path.clone()));
let _setup_type_guard = SetupTypeGuard::switch_to(&set_disks.ctx, SetupType::DistErasure).await;
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::BeforeLockLost);
let complete_store = set_disks.clone();
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@
//! another object (`a/b` and `a/b/c`). Cleanup of the parent must only remove
//! the parent's own protocol data and never touch the child's xl.meta or data.
use super::hermetic_set_disks_support::{HermeticSetFixture, hermetic_set_disks_isolated};
use super::hermetic_set_disks_support::hermetic_set_disks_isolated;
use super::*;
use crate::data_movement::{SourceCleanupBucketFence, SourceCleanupError, cleanup_source_entry_if_unchanged};
use rustfs_filemeta::FileInfoVersions;
@@ -100,7 +100,7 @@ fn child_tree(temp_dirs: &[TempDir], bucket: &str) -> Vec<Vec<String>> {
.collect()
}
async fn setup(payload_len: usize) -> (HermeticSetFixture, Arc<SetDisks>, &'static str, Vec<u8>, Vec<u8>) {
async fn setup(payload_len: usize) -> (Vec<TempDir>, Arc<SetDisks>, &'static str, Vec<u8>, Vec<u8>) {
let (temp_dirs, disk_stores, set_disks) = hermetic_set_disks_isolated(4).await;
let bucket = "data-movement-prefix-cleanup";
for disk in &disk_stores {
+2 -1
View File
@@ -3885,7 +3885,8 @@ mod metadata_cache_tests {
let bucket = "late-metadata-identity";
let object = "late-metadata-identity-object";
let (_fixture, _disks, set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
let (_dirs, set) = crate::ecstore_validation_blackbox::make_local_set_disks_with_ctx(4, 2, ctx).await;
set.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create real late-read bucket");
@@ -8,7 +8,6 @@ pub(crate) mod admin {
}
pub(crate) mod bucket {
pub(crate) use rustfs_storage_api::bucket::valid_artifact_base_prefix;
pub(crate) use rustfs_storage_api::{
BUCKET_LIFECYCLE_LOCK_OBJECT, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions,
SRBucketDeleteOp,
+228 -269
View File
@@ -848,293 +848,255 @@ impl ECStore {
#[instrument(skip(self))]
pub(super) async fn handle_make_bucket(&self, bucket: &str, opts: &MakeBucketOptions) -> Result<()> {
self.handle_make_bucket_inner(bucket, opts).await
}
if !is_meta_bucketname(bucket)
&& let Err(err) = check_valid_bucket_name_strict(bucket)
{
return Err(StorageError::BucketNameInvalid(err.to_string()));
}
// Keep the large creation future out of the instrumented handler's poll
// frame. Guards remain owned by the same task.
fn handle_make_bucket_inner<'a>(
&'a self,
bucket: &'a str,
opts: &'a MakeBucketOptions,
) -> futures::future::BoxFuture<'a, Result<()>> {
Box::pin(async move {
if opts.artifact_base_prefix.as_deref().is_some_and(|prefix| {
is_meta_bucketname(bucket)
|| opts.no_lock
|| !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(prefix)
}) {
return Err(Error::other("invalid artifact bucket creation options"));
}
if !is_meta_bucketname(bucket)
&& let Err(err) = check_valid_bucket_name_strict(bucket)
{
return Err(StorageError::BucketNameInvalid(err.to_string()));
}
// Lock order: bucket lifecycle -> metadata transaction -> exact bucket namespace.
let bucket_lifecycle_guard = if !opts.no_lock {
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
} else {
None
};
let metadata_transaction_guard = if !opts.no_lock && !is_meta_bucketname(bucket) {
Some(metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?)
} else {
None
};
let ns_guard = if !opts.no_lock {
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
Some(
await_bucket_namespace_operation(
bucket_lifecycle_guard.as_ref(),
bucket,
"bucket namespace lock acquisition",
async {
ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e {
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
StorageError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: bucket.to_string(),
object: bucket.to_string(),
required,
achieved,
}
// Lock order: bucket lifecycle -> metadata transaction -> exact bucket namespace.
let bucket_lifecycle_guard = if !opts.no_lock {
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
} else {
None
};
let metadata_transaction_guard = if !opts.no_lock && !is_meta_bucketname(bucket) {
Some(metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, bucket).await?)
} else {
None
};
let ns_guard = if !opts.no_lock {
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
Some(
await_bucket_namespace_operation(
bucket_lifecycle_guard.as_ref(),
bucket,
"bucket namespace lock acquisition",
async {
ns_lock.get_write_lock(get_lock_acquire_timeout()).await.map_err(|e| match e {
rustfs_lock::error::LockError::QuorumNotReached { required, achieved } => {
StorageError::NamespaceLockQuorumUnavailable {
mode: "write",
bucket: bucket.to_string(),
object: bucket.to_string(),
required,
achieved,
}
other => StorageError::Lock(other),
})
},
)
.await?,
}
other => StorageError::Lock(other),
})
},
)
} else {
None
};
.await?,
)
} else {
None
};
let existing_bucket_info = match self.get_bucket_info_from_sets(bucket, &BucketOptions::default()).await {
Ok(info) => Some(info),
Err(err) => {
if is_err_bucket_not_found(&err) {
None
} else {
return Err(to_object_err(err, vec![bucket]));
}
}
};
let confirmed_missing = existing_bucket_info.is_none();
let mut pending_creation_metadata = None;
if confirmed_missing
&& !opts.no_lock
&& !is_meta_bucketname(bucket)
&& let Some(metadata_store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await
&& let Some(old_incarnation) = crate::bucket::metadata::load_bucket_incarnation(metadata_store, bucket).await?
{
// A quorum-level absence is not proof that every disk lost the old
// generation. Require the explicit recovery path to verify all disks
// and retire this incarnation before a same-name create can mutate
// any volume.
self.ensure_bucket_volume_absent_on_all_sets(bucket)
.await
.map_err(|error| match error {
StorageError::BucketExists(_) => StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
"an old bucket volume remains on disk; administrator recovery refused".to_string(),
),
other => other,
})?;
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
let metadata_store = metadata_sys::object_store_in(&self.ctx).await?;
let pending_intent = persisted
&& metadata.bucket_incarnation_id == old_incarnation
&& metadata.needs_bucket_creation_commit()
&& !crate::bucket::retirement::is_retired(metadata_store, bucket, old_incarnation).await?;
if pending_intent {
pending_creation_metadata = Some(metadata);
let existing_bucket_info = match self.get_bucket_info_from_sets(bucket, &BucketOptions::default()).await {
Ok(info) => Some(info),
Err(err) => {
if is_err_bucket_not_found(&err) {
None
} else {
return Err(StorageError::InvalidArgument(
return Err(to_object_err(err, vec![bucket]));
}
}
};
let confirmed_missing = existing_bucket_info.is_none();
let mut pending_creation_metadata = None;
if confirmed_missing
&& !opts.no_lock
&& !is_meta_bucketname(bucket)
&& let Some(metadata_store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await
&& let Some(old_incarnation) = crate::bucket::metadata::load_bucket_incarnation(metadata_store, bucket).await?
{
// A quorum-level absence is not proof that every disk lost the old
// generation. Require the explicit recovery path to verify all disks
// and retire this incarnation before a same-name create can mutate
// any volume.
self.ensure_bucket_volume_absent_on_all_sets(bucket)
.await
.map_err(|error| match error {
StorageError::BucketExists(_) => StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
));
}
}
let existing_metadata = if let Some(metadata) = pending_creation_metadata {
Some(metadata)
} else if (opts.force_create || opts.artifact_base_prefix.is_some())
&& !confirmed_missing
&& !is_meta_bucketname(bucket)
{
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
if !persisted {
metadata = BucketMetadata::new(bucket);
metadata.created = existing_bucket_info
.as_ref()
.and_then(|info| info.created)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// A sidecar left by an interrupted legacy migration is the
// bucket's published incarnation; keep it rather than minting
// one that would invalidate fences taken on the stored value.
// A retired incarnation is residue of a deleted bucket and
// must not come back.
let store = metadata_sys::object_store_in(&self.ctx).await?;
if let Some(stored) = crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket).await?
&& !crate::bucket::retirement::is_retired(store, bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
return Err(Error::other(format!(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
)));
} else if !metadata.bucket_incarnation_sidecar {
metadata.bucket_incarnation_id = Uuid::new_v4();
}
Some(metadata)
"an old bucket volume remains on disk; administrator recovery refused".to_string(),
),
other => other,
})?;
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
let metadata_store = metadata_sys::object_store_in(&self.ctx).await?;
let pending_intent = persisted
&& metadata.bucket_incarnation_id == old_incarnation
&& metadata.needs_bucket_creation_commit()
&& !crate::bucket::retirement::is_retired(metadata_store, bucket, old_incarnation).await?;
if pending_intent {
pending_creation_metadata = Some(metadata);
} else {
None
};
let has_creation_identity = existing_metadata.is_some();
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)
}
});
if let Some(prefix) = &opts.artifact_base_prefix {
if has_creation_identity {
if meta.artifact_base_prefix.as_ref() != Some(prefix) {
return Err(Error::other("artifact namespace is immutable"));
}
} else if confirmed_missing {
meta.artifact_base_prefix = Some(prefix.clone());
} else {
return Err(Error::other("artifact namespace requires a new bucket"));
}
return Err(StorageError::InvalidArgument(
"CreateBucket".to_string(),
"bucket".to_string(),
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
));
}
let existing_incarnation_is_authoritative = meta.bucket_incarnation_sidecar;
if confirmed_missing || is_meta_bucketname(bucket) {
meta.set_created(opts.created_at);
if opts.versioning_enabled || meta.artifact_base_prefix.is_some() {
meta.versioning_config_xml =
crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
}
let existing_metadata = if let Some(metadata) = pending_creation_metadata {
Some(metadata)
} else if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
if !persisted {
metadata = BucketMetadata::new(bucket);
metadata.created = existing_bucket_info
.as_ref()
.and_then(|info| info.created)
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
// A sidecar left by an interrupted legacy migration is the
// bucket's published incarnation; keep it rather than minting
// one that would invalidate fences taken on the stored value.
// A retired incarnation is residue of a deleted bucket and
// must not come back.
let store = metadata_sys::object_store_in(&self.ctx).await?;
if let Some(stored) = crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket).await?
&& !crate::bucket::retirement::is_retired(store, bucket, stored).await?
{
metadata.bucket_incarnation_id = stored;
}
} else if !metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() {
return Err(Error::other(format!(
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
)));
} else if !metadata.bucket_incarnation_sidecar {
metadata.bucket_incarnation_id = Uuid::new_v4();
}
Some(metadata)
} else {
None
};
// Object Lock enable is one-way, and it must apply to an existing bucket
// too. Site replication replays make-with-versioning carrying the
// source's lockEnabled against a destination bucket that already exists;
// gating this on `confirmed_missing` returned success while leaving the
// replica unlocked, so replicated versions could be deleted without the
// retention the source enforces.
let lock_newly_enabled = opts.lock_enabled && !meta.lock_enabled;
if opts.lock_enabled {
meta.lock_enabled = true;
meta.object_lock_config_xml = crate::bucket::utils::serialize(&*crate::store::ENABLED_OBJECT_LOCK_CONFIG)?;
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);
if opts.versioning_enabled {
meta.versioning_config_xml =
crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
}
}
let metadata_persisted_before_physical =
confirmed_missing && !is_meta_bucketname(bucket) && (opts.lock_enabled || meta.artifact_base_prefix.is_some());
if metadata_persisted_before_physical {
metadata_sys::set_new_bucket_metadata_intent_in(&self.ctx, meta.clone()).await?;
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
{
return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}")));
}
}
// Object Lock enable is one-way, and it must apply to an existing bucket
// too. Site replication replays make-with-versioning carrying the
// source's lockEnabled against a destination bucket that already exists;
// gating this on `confirmed_missing` returned success while leaving the
// replica unlocked, so replicated versions could be deleted without the
// retention the source enforces.
let lock_newly_enabled = opts.lock_enabled && !meta.lock_enabled;
if opts.lock_enabled {
meta.lock_enabled = true;
meta.object_lock_config_xml = crate::bucket::utils::serialize(&*crate::store::ENABLED_OBJECT_LOCK_CONFIG)?;
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// Fence every scanner cycle that could have observed the namespace
// before physical creation. Creation may become visible even when a
// later metadata write or namespace-lock check fails.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
// Keep cleanup state out of the bucket-creation request future.
Box::pin(self.cleanup_bucket_usage(bucket, ns_guard.as_ref())).await?;
}
if let Err(err) = await_bucket_lifecycle_operation(
bucket_lifecycle_guard.as_ref(),
ns_guard.as_ref(),
bucket,
"physical bucket creation",
await_bucket_namespace_operation(
metadata_transaction_guard.as_ref(),
bucket,
"bucket creation metadata transaction",
async {
self.make_bucket_on_sets(bucket, opts)
.await
.map_err(|err| to_object_err(err, vec![bucket]))
},
),
)
.await
{
if is_err_bucket_exists(&err)
&& let Err(heal_err) = self
.handle_heal_bucket(
bucket,
&HealOpts {
recreate: true,
..Default::default()
},
)
.await
{
warn!("best-effort bucket heal after BucketExists failed: {heal_err}");
}
if confirmed_missing && !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost())
{
error!("make bucket failed: {err}");
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
};
let metadata_result = async {
if metadata_persisted_before_physical {
return metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await;
}
if is_meta_bucketname(bucket) {
metadata_sys::set_bucket_metadata_in(&self.ctx, meta).await
} else if existing_incarnation_is_authoritative && !lock_newly_enabled {
metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await
} else {
metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta).await
}
}
.await;
let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
let metadata_persisted_before_physical = confirmed_missing && !is_meta_bucketname(bucket) && opts.lock_enabled;
if metadata_persisted_before_physical {
metadata_sys::set_new_bucket_metadata_intent_in(&self.ctx, meta.clone()).await?;
if bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
if let Err(err) = metadata_result {
if !metadata_lock_lost {
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
}
if metadata_lock_lost {
return Err(Error::other(format!("bucket metadata initialization lock was lost: {bucket}")));
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
{
return Err(Error::other(format!("bucket metadata creation intent lock was lost: {bucket}")));
}
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// A scanner may have sampled the first fence before the bucket
// became visible. Fence again after metadata initialization so
// that snapshot cannot publish as complete.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// Fence every scanner cycle that could have observed the namespace
// before physical creation. Creation may become visible even when a
// later metadata write or namespace-lock check fails.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
self.cleanup_bucket_usage(bucket, ns_guard.as_ref()).await?;
}
Ok(())
})
if let Err(err) = await_bucket_lifecycle_operation(
bucket_lifecycle_guard.as_ref(),
ns_guard.as_ref(),
bucket,
"physical bucket creation",
await_bucket_namespace_operation(
metadata_transaction_guard.as_ref(),
bucket,
"bucket creation metadata transaction",
async {
self.make_bucket_on_sets(bucket, opts)
.await
.map_err(|err| to_object_err(err, vec![bucket]))
},
),
)
.await
{
if is_err_bucket_exists(&err)
&& let Err(heal_err) = self
.handle_heal_bucket(
bucket,
&HealOpts {
recreate: true,
..Default::default()
},
)
.await
{
warn!("best-effort bucket heal after BucketExists failed: {heal_err}");
}
if confirmed_missing && !is_err_bucket_exists(&err) && ns_guard.as_ref().is_none_or(|guard| !guard.is_lock_lost()) {
error!("make bucket failed: {err}");
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
};
let metadata_result = async {
if metadata_persisted_before_physical {
return metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await;
}
if is_meta_bucketname(bucket) {
metadata_sys::set_bucket_metadata_in(&self.ctx, meta).await
} else if existing_incarnation_is_authoritative && !lock_newly_enabled {
metadata_sys::commit_bucket_metadata_in(&self.ctx, meta).await
} else {
metadata_sys::set_new_bucket_metadata_in(&self.ctx, meta).await
}
}
.await;
let metadata_lock_lost = bucket_lifecycle_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| metadata_transaction_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
|| ns_guard.as_ref().is_some_and(|guard| guard.is_lock_lost());
if let Err(err) = metadata_result {
if !metadata_lock_lost {
self.rollback_failed_bucket_creation(bucket, bucket_lifecycle_guard.as_ref(), ns_guard.as_ref())
.await;
}
return Err(err);
}
if metadata_lock_lost {
return Err(Error::other(format!("bucket metadata initialization lock was lost: {bucket}")));
}
if confirmed_missing && !is_meta_bucketname(bucket) {
// A scanner may have sampled the first fence before the bucket
// became visible. Fence again after metadata initialization so
// that snapshot cannot publish as complete.
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
}
Ok(())
}
/// Prove a live bucket generation before repairing missing expansion volumes.
@@ -1499,9 +1461,6 @@ impl ECStore {
}
};
if bucket_exists {
if !is_meta_bucketname(bucket) {
self.require_no_artifact_backup_retention(bucket).await?;
}
validate_table_bucket_delete_guard(&self.ctx, bucket).await?;
if !opts.force {
+1 -43
View File
@@ -160,7 +160,6 @@ pub struct BucketIncarnationFenceGuard {
bucket: String,
token: Option<u64>,
memoized: Option<Uuid>,
validated_incarnation: Option<Uuid>,
}
impl BucketIncarnationFenceGuard {
@@ -174,7 +173,6 @@ impl BucketIncarnationFenceGuard {
bucket: bucket.to_owned(),
token,
memoized,
validated_incarnation: None,
}
}
@@ -185,7 +183,6 @@ impl BucketIncarnationFenceGuard {
F: FnOnce() -> Fut,
Fut: Future<Output = Result<Uuid>>,
{
self.validated_incarnation = None;
let cacheable = reuse_memo && self.token.is_some();
let current = match self.memoized.filter(|_| cacheable) {
Some(current) => current,
@@ -200,7 +197,6 @@ impl BucketIncarnationFenceGuard {
}
};
self.ensure_held()?;
self.validated_incarnation = Some(current);
Ok(current)
}
@@ -217,19 +213,6 @@ impl BucketIncarnationFenceGuard {
Ok(())
}
pub(crate) fn matches(&self, store: &super::ECStore, bucket: &str, incarnation: Uuid) -> bool {
Arc::ptr_eq(&self.registry, &store.bucket_fence_registry)
&& self.bucket == bucket
&& self.validated_incarnation == Some(incarnation)
&& !self.is_lock_lost()
}
/// Retain this validated lifecycle owner through the storage write tail.
pub fn attach_owner(self: &Arc<Self>, opts: &mut crate::object_api::ObjectOptions) {
self.attach_to_object_options(opts);
opts.bucket_incarnation_fence = Some(Arc::clone(self));
}
/// Propagate lifecycle lock loss into the storage commit checks.
/// The caller still owns this guard until the complete write tail drains.
pub fn attach_to_object_options(&self, opts: &mut crate::object_api::ObjectOptions) {
@@ -238,8 +221,7 @@ impl BucketIncarnationFenceGuard {
}
}
/// Report whether the lifecycle lock no longer covers this operation.
pub fn is_lock_lost(&self) -> bool {
pub(crate) fn is_lock_lost(&self) -> bool {
self.inner.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
}
@@ -419,30 +401,6 @@ mod tests {
BucketIncarnationFenceGuard::new(registry, "b", inner, cacheable)
}
#[tokio::test]
async fn artifact_owner_tracks_validation_instead_of_cached_incarnation() {
let registry = Arc::new(BucketFenceRegistry::default());
let lock = NamespaceLock::new("artifact-owner-validation".to_owned(), Arc::new(LocalClient::new()));
let mut first = test_fence(registry.clone(), &lock, true).await;
first.validate(true, || async { Ok(uuid(7)) }).await.expect("seed memo");
let mut snapshot = test_fence(registry, &lock, true).await;
assert_eq!(snapshot.memoized, Some(uuid(7)));
assert_eq!(snapshot.validated_incarnation, None, "registration alone grants no artifact ownership");
snapshot
.validate(false, || async { Ok(uuid(8)) })
.await
.expect("validate snapshot");
assert_eq!(snapshot.validated_incarnation, Some(uuid(8)));
assert_eq!(snapshot.memoized, Some(uuid(7)), "independent snapshots do not update the shared memo");
assert!(
snapshot
.validate(false, || async { Err(StorageError::ErasureReadQuorum) })
.await
.is_err()
);
assert_eq!(snapshot.validated_incarnation, None, "failed validation cannot retain earlier ownership");
}
#[tokio::test]
async fn overlapping_validations_reuse_one_success_until_coverage_gap() {
use std::sync::atomic::{AtomicUsize, Ordering};
+133 -59
View File
@@ -39,10 +39,6 @@ pub(crate) struct BucketHealScope {
}
impl BucketHealScope {
pub(crate) fn lifecycle_guard(&self) -> Option<&NamespaceLockGuard> {
self.fence.namespace_lock_guard()
}
pub(crate) fn check(&self) -> disk::error::Result<()> {
if self.fence.is_lock_lost() {
return Err(DiskError::other("bucket heal incarnation fence was lost"));
@@ -603,6 +599,33 @@ impl ECStore {
Ok((write_state, pool_guard, rebalance_guard, pool_meta, rebalance_meta))
}
/// Snapshot only the object sets participating in this request. The sampler
/// resolves their physical leaves before Heal enters namespace/cache locks.
pub fn physical_heal_plan(&self, object: &str, opts: &HealOpts) -> Result<Option<crate::disk::PhysicalResourcePlan>> {
if let Some(index) = opts.pool
&& index >= self.pools.len()
{
return Err(invalid_heal_pool_index(index, self.pools.len()));
}
let object = rustfs_utils::path::encode_dir_object_ref(object);
let mut selected: Option<crate::disk::PhysicalResourcePlan> = None;
for (index, pool) in self.pools.iter().enumerate() {
if opts.pool.is_some_and(|requested| requested != index) {
continue;
}
let set = pool.get_disks_for_heal_object(&object, opts)?;
let Some(plan) = set.physical_heal_admission.plan() else { return Ok(None) };
if let Some(selected) = &mut selected {
if selected.union_with(&plan).is_err() {
return Ok(None);
}
} else {
selected = Some(plan);
}
}
Ok(selected)
}
fn get_pools_for_heal_object(&self, opts: &HealOpts) -> Result<Vec<Arc<Sets>>> {
match opts.pool {
Some(pool_idx) => Ok(vec![
@@ -1074,25 +1097,16 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
) -> Result<(HealResultItem, Option<Error>)> {
crate::bucket::artifact::with_artifact_repair_scope(
&self.ctx,
self.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
Box::pin(async {
self.handle_heal_object_with_absence(
bucket,
object,
version_id,
opts,
&mut None,
crate::set_disk::AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}),
&mut None,
crate::set_disk::AbsenceProofRequest {
retirement: None,
allow_unversioned: false,
},
)
.await
}
@@ -1126,24 +1140,6 @@ impl ECStore {
version_id: &str,
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
crate::bucket::artifact::with_artifact_repair_scope(
&self.ctx,
bucket,
object,
opts,
Box::pin(self.heal_object_with_absence_proof_inner(bucket, object, version_id, opts, allow_unversioned_absence)),
)
.await
}
async fn heal_object_with_absence_proof_inner(
&self,
bucket: &str,
object: &str,
version_id: &str,
opts: &HealOpts,
allow_unversioned_absence: bool,
) -> Result<HealObjectStorageResult> {
// Object heal may recreate a missing bucket volume before committing a
// shard. Keep the current bucket generation fenced for the whole
@@ -1162,11 +1158,7 @@ impl ECStore {
// Reuse the admission's owner: reacquiring a read lock behind a queued
// lifecycle writer would deadlock with the read lock we already hold.
let scope = bucket_heal_scope(bucket).filter(|scope| std::ptr::eq(scope.store.as_ref(), self));
let repair = crate::bucket::artifact::artifact_repair_scope(bucket, object);
let guard = if let Some(repair) = &repair {
repair.check()?;
None
} else if let Some(scope) = &scope {
let guard = if let Some(scope) = &scope {
scope.check().map_err(Error::from)?;
None
} else {
@@ -1174,13 +1166,11 @@ impl ECStore {
};
let fence_is_lost = || {
scope.as_ref().is_some_and(|scope| scope.fence.is_lock_lost())
|| repair.as_ref().is_some_and(|scope| scope.check().is_err())
|| guard.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
};
let lifecycle_guard = scope
.as_ref()
.and_then(|scope| scope.fence.namespace_lock_guard())
.or_else(|| repair.as_ref().and_then(|scope| scope.lifecycle_guard()))
.or(guard.as_ref())
.ok_or_else(|| Error::other("bucket heal requires a held lifecycle guard"))?;
let retirement = crate::bucket::retirement::MarkerRetirementContext {
@@ -1591,21 +1581,25 @@ mod tests {
}
}
async fn minimal_heal_pool(pool_idx: usize) -> Arc<Sets> {
let format = FormatV3::new(1, 1);
let endpoint_url = format!("http://127.0.0.1:{}/data", 19000 + pool_idx);
let mut endpoint = Endpoint::try_from(endpoint_url.as_str()).expect("endpoint should parse");
endpoint.set_pool_index(pool_idx);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
async fn minimal_heal_pool(pool_idx: usize, set_count: usize) -> Arc<Sets> {
let format = FormatV3::new(set_count, 1);
let endpoints = (0..set_count)
.map(|set_idx| {
let endpoint_url = format!("http://127.0.0.1:{}/data", 19000 + pool_idx + set_idx * 2);
let mut endpoint = Endpoint::try_from(endpoint_url.as_str()).expect("endpoint should parse");
endpoint.set_pool_index(pool_idx);
endpoint.set_set_index(set_idx);
endpoint.set_disk_index(0);
endpoint
})
.collect::<Vec<_>>();
Sets::new(
vec![None],
vec![None; set_count],
&PoolEndpoints {
legacy: false,
set_count: 1,
set_count,
drives_per_set: 1,
endpoints: Endpoints::from(vec![endpoint]),
endpoints: Endpoints::from(endpoints),
cmd_line: String::new(),
platform: String::new(),
},
@@ -2070,7 +2064,7 @@ mod tests {
ECStore {
id: Uuid::new_v4(),
disk_map: HashMap::new(),
pools: vec![minimal_heal_pool(0).await, minimal_heal_pool(1).await],
pools: vec![minimal_heal_pool(0, 1).await, minimal_heal_pool(1, 1).await],
peer_sys: S3PeerSys {
clients: Vec::new(),
pools_count: 2,
@@ -2082,7 +2076,88 @@ mod tests {
pool_meta_save_gate: Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
}
}
#[tokio::test]
async fn physical_heal_plan_uses_the_real_pool_set_and_directory_placement() {
use crate::disk::PhysicalDiskBinding;
use rustfs_concurrency::adaptive::{
AdaptiveAdmission, FeedbackController, FeedbackObservation, NodeAdmission, ResourcePressure,
};
use std::num::NonZeroU16;
let mut store = minimal_heal_store().await;
store.pools[0] = minimal_heal_pool(0, 2).await;
let pool = &store.pools[0];
let a = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
let b = AdaptiveAdmission::new(NonZeroU16::MIN, 1);
for (set, gate) in pool.disk_set.iter().zip([&a, &b]) {
let binding = PhysicalDiskBinding::default();
binding.set_test_capacity(vec![gate.clone()]).expect("physical fixture");
set.physical_heal_admission
.set_test_plan(binding.plan().expect("live").resource_plan());
}
let node = NodeAdmission::new(NonZeroU16::MIN);
node.update(
&mut FeedbackController::new(),
Some(FeedbackObservation {
generation: 1,
max_concurrency: 1,
pressure: ResourcePressure::Unknown,
completed_units: None,
}),
);
let directory = (0..100)
.map(|n| format!("dir{n}/"))
.find(|name| {
store.pools[0].get_disks_by_key(name).set_index
!= store.pools[0].get_disks_by_key(&encode_dir_object(name)).set_index
})
.expect("different directory placement");
for object in ["plain-object", directory.as_str()] {
for set in [None, Some(0), Some(1)] {
let opts = HealOpts {
pool: Some(0),
set,
..Default::default()
};
let expected = store.pools[0]
.get_disks_for_heal_object(&encode_dir_object(object), &opts)
.expect("placement")
.set_index;
let permit = store
.physical_heal_plan(object, &opts)
.expect("valid selection")
.expect("known set")
.try_acquire(&node)
.expect("physical admission");
assert_eq!((a.snapshot().active, b.snapshot().active), if expected == 0 { (1, 0) } else { (0, 1) });
drop(permit);
}
}
assert!(
store
.physical_heal_plan("object", &HealOpts::default())
.expect("valid all pools")
.is_none(),
"an undiscovered participating pool cannot be skipped"
);
for opts in [
HealOpts {
pool: Some(2),
..Default::default()
},
HealOpts {
pool: Some(0),
set: Some(2),
..Default::default()
},
] {
assert!(matches!(
store.physical_heal_plan("object", &opts),
Err(StorageError::InvalidArgument(..))
));
}
}
@@ -4186,7 +4261,6 @@ mod tests {
pool_meta_save_gate: Mutex::default(),
ctx: crate::runtime::instance::bootstrap_ctx(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
};
let err = store
+7 -169
View File
@@ -428,7 +428,6 @@ impl ECStore {
let mut deployment_id = None;
let mut pool_meta_bootstrap_authorities = Vec::new();
let mut pending_format_disks = Vec::new();
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
@@ -554,7 +553,6 @@ impl ECStore {
// formatted it, `LegacyAdoption` when it verified the migration, and
// `None` when it merely read a format another node created.
pool_meta_bootstrap_authorities.push(loaded_format.pool_meta_bootstrap_authority);
pending_format_disks.extend(loaded_format.pending_disks);
let fm = loaded_format.format;
// Format loading succeeded, enable health monitoring on all disks
@@ -620,7 +618,6 @@ impl ECStore {
// this point) and later reads share one cell.
ctx: instance_ctx.clone(),
bucket_fence_registry: std::sync::Arc::default(),
ordinary_classification: Default::default(),
});
// Only set it when this instance's deployment ID is not yet configured
@@ -630,21 +627,8 @@ impl ECStore {
let wait_sec = 5;
let mut exit_count = 0;
let disk_sets = ec
.pools
.iter()
.flat_map(|pool| pool.disk_set.iter().cloned())
.collect::<Vec<_>>();
loop {
let initialization = async {
ec.init(ctx.clone()).await?;
for pending in &pending_format_disks {
pending.finalize(&instance_ctx, &disk_sets).await?;
}
Ok::<(), Error>(())
}
.await;
if let Err(err) = initialization {
if let Err(err) = ec.init(ctx.clone()).await {
error!("init err: {}", err);
error!("retry after {} second", wait_sec);
sleep(Duration::from_secs(wait_sec)).await;
@@ -668,11 +652,6 @@ impl ECStore {
#[instrument(level = "debug", skip(self, rx))]
pub async fn init(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
// Keep startup state out of callers and the instrumented wrapper.
Box::pin(self.init_inner(rx)).await
}
async fn init_inner(self: &Arc<Self>, rx: CancellationToken) -> Result<()> {
runtime_sources::ensure_boot_time().await;
let should_persist_pool_meta = self
@@ -890,23 +869,6 @@ impl ECStore {
#[cfg(test)]
mod tests {
#[cfg(feature = "test-util")]
mod artifact_bucket_tests;
#[cfg(unix)]
mod artifact_inventory_tests;
#[cfg(feature = "test-util")]
mod artifact_put_seal_tests;
#[cfg(all(feature = "test-util", unix))]
mod artifact_receiver_tests;
#[cfg(feature = "test-util")]
mod artifact_repair_tests;
#[cfg(feature = "test-util")]
mod artifact_retirement_tests;
#[cfg(feature = "test-util")]
mod artifact_write_entrypoint_tests;
#[cfg(all(feature = "test-util", unix))]
mod artifact_writes_seal_tests;
use super::{
LOCAL_DECOMMISSION_RESUME_RETRY_DELAY, LOCAL_DECOMMISSION_WATCHDOG_MAX_RETRY_DELAY, PoolMetaWriteState,
establish_pool_meta_bootstrap_identity_if_proven, load_pool_meta_for_startup, local_decommission_watchdog_retry_delay,
@@ -1819,7 +1781,7 @@ mod tests {
let mut pools = Vec::new();
for _ in 0..4 {
let (pool_dirs, pool_disks, pool) = hermetic_set_disks_isolated(4).await;
dirs.push(pool_dirs);
dirs.extend(pool_dirs);
disks.push(pool_disks);
pools.push(pool);
}
@@ -7336,8 +7298,8 @@ mod tests {
)
.await
.expect("seed older target metadata on all disks");
let source_set = store.pools[0].get_disks_by_key(object);
let mut source = source_set
let source = store.pools[0]
.get_disks_by_key(object)
.load_file_info_versions_exact(&bucket, object)
.await
.expect("read source marker metadata")
@@ -7346,30 +7308,6 @@ mod tests {
.into_iter()
.find(|version| version.version_id.unwrap_or_default() == marker_id)
.expect("find source delete marker");
if marker_incarnation.is_none() {
// Public writes bind new markers to the current bucket generation.
// Seed legacy disk metadata explicitly without weakening admission.
rustfs_utils::http::metadata_compat::remove_str(
&mut source.metadata,
rustfs_utils::http::metadata_compat::SUFFIX_BUCKET_INCARNATION_ID,
);
for disk in source_set.disk_inventory().await.into_iter().flatten() {
disk.write_metadata("", &bucket, object, source.clone())
.await
.expect("seed legacy source marker metadata");
}
source_set.invalidate_get_object_metadata_cache(&bucket, object).await;
}
let source = source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("reload source marker metadata")
.expect("source marker must remain present")
.versions
.into_iter()
.find(|version| version.version_id.unwrap_or_default() == marker_id)
.expect("find reloaded source delete marker");
assert_eq!(source.delete_marker_incarnation(), marker_incarnation, "source fixture identity");
let target_set = store.pools[1].get_disks_by_key(object);
for disk in target_set.disk_inventory().await.into_iter().flatten().take(2) {
disk.write_metadata("", &bucket, object, source.clone())
@@ -7795,103 +7733,6 @@ mod tests {
.expect_err("suspended delete must remove the requested UUID version");
}
#[tokio::test]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_retains_lifecycle_owner_through_source_cleanup() {
use crate::disk::DiskAPI as _;
let temp_dir = tempfile::tempdir().expect("create decommission lifecycle-owner store");
let (_ctx, store, shutdown) = Box::pin(without_storage_class_env(build_isolated_test_store(
temp_dir.path(),
"decommission-lifecycle-owner",
&[4, 4],
)))
.await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let bucket = format!("decom-lifecycle-owner-{}", uuid::Uuid::new_v4());
let object = "source.bin";
let payload = vec![b'o'; 1_300_000];
Box::pin(store.make_bucket(&bucket, &MakeBucketOptions::default()))
.await
.expect("create ordinary source bucket");
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("source bucket incarnation");
let opts = ObjectOptions {
expected_bucket_incarnation_id: Some(incarnation),
..Default::default()
};
Box::pin(seed_decommission_source(&store, &bucket, object, payload.clone(), &opts)).await;
let source_set = store.pools[0].get_disks_by_key(object);
let expected = source_set
.load_file_info_versions_exact(&bucket, object)
.await
.expect("load the exact cleanup snapshot")
.expect("source object must exist");
let source_disks = source_set.disk_inventory().await;
assert_eq!(source_disks.iter().flatten().count(), 4, "all source disks must be online");
let owner = store
.acquire_bucket_incarnation_fence(&bucket, incarnation)
.await
.expect("hold source bucket lifecycle");
let rejected = Box::pin(crate::data_movement::cleanup_source_entry_if_unchanged(
Arc::clone(&source_set),
&bucket,
object,
&expected,
&[],
&[],
crate::data_movement::SourceCleanupBucketFence {
expected_incarnation_id: Some(incarnation),
lifecycle_guard: owner.namespace_lock_guard(),
..Default::default()
},
"test_unowned_source_cleanup",
))
.await
.expect_err("a bare lifecycle signal must not authorize source cleanup");
assert!(
rejected
.to_string()
.contains("write requires an owned bucket lifecycle fence"),
"cleanup must reject the missing owner: {rejected}"
);
let mut source = source_set
.get_object_reader(&bucket, object, None, HeaderMap::new(), &opts)
.await
.expect("rejected cleanup must preserve the readable source");
let mut remaining = Vec::new();
source
.stream
.read_to_end(&mut remaining)
.await
.expect("drain preserved source");
assert_eq!(remaining, payload);
drop(source);
drop(owner);
mark_test_pool_decommissioning(&store, 0).await;
Box::pin(run_decommission_entry_retry_test(
&store,
CancellationToken::new(),
&bucket,
object,
Some(incarnation),
Arc::new(AtomicUsize::new(0)),
))
.await
.expect("decommission must copy and clean the ordinary bucket entry with its real owner");
assert_eq!(read_decommission_target_body(&store, &bucket, object, &opts).await, payload);
assert_decommission_source_absent(&store, &bucket, object, &opts).await;
for disk in source_disks.into_iter().flatten() {
assert!(
!tokio::fs::try_exists(disk.path().join(&bucket).join(object))
.await
.expect("inspect the physical source path"),
"source metadata and shards must be removed from every disk"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn decommission_entry_carries_migration_and_cleanup_mutation_fences() {
@@ -21232,13 +21073,10 @@ mod tests {
}
#[cfg(feature = "test-util")]
#[test]
#[tokio::test]
#[serial_test::serial(storage_class_env)]
fn legacy_recovery_disposition_removes_only_local_journals_and_replays() {
run_large_stack_async_test(
"legacy-recovery-disposition",
legacy_recovery_disposition_removes_only_local_journals_and_replays_case,
);
async fn legacy_recovery_disposition_removes_only_local_journals_and_replays() {
Box::pin(legacy_recovery_disposition_removes_only_local_journals_and_replays_case()).await;
}
#[cfg(feature = "test-util")]
@@ -1,757 +0,0 @@
use super::{build_isolated_test_store, without_storage_class_env};
use crate::{
bucket::{
metadata::{
BUCKET_INCARNATION_FILE, BUCKET_METADATA_FILE, BUCKET_TAGGING_CONFIG, BUCKET_VERSIONING_CONFIG, BucketMetadata,
load_bucket_metadata_parse_with_presence,
},
metadata_sys,
versioning::VersioningApi,
},
disk::{BUCKET_META_PREFIX, DiskAPI as _, RUSTFS_META_BUCKET},
runtime::instance::InstanceContext,
storage_api_contracts::{
bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions},
namespace::NamespaceLocking as _,
},
store::ECStore,
};
use std::{sync::Arc, time::Duration};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const PREFIX: &str = "outputs/";
const ENABLED: &[u8] = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>";
const SUSPENDED: &[u8] = b"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>";
const EXCLUDED_PREFIX: &[u8] = b"<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>outputs/</Prefix></ExcludedPrefixes></VersioningConfiguration>";
const EXCLUDED_FOLDERS: &[u8] =
b"<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>";
const TAGGING: &[u8] = b"<Tagging><TagSet><Tag><Key>purpose</Key><Value>artifact-test</Value></Tag></TagSet></Tagging>";
async fn open_store(root: &std::path::Path) -> (Arc<InstanceContext>, Arc<ECStore>, CancellationToken) {
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root, "artifact-bucket", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(ctx, store, shutdown)
}
async fn drain_before_reopen(store: &ECStore, shutdown: &CancellationToken, bucket: &str) {
shutdown.cancel();
assert!(
store.ctx.wait_for_detached_mutations(Duration::from_secs(30)).await,
"ARTIFACT_PREVIOUS_STORE_MUTATIONS_DRAINED"
);
// Internal metadata PUTs can acknowledge quorum before their disk tails finish.
// Acquire each key separately to wait for its actual writer to release the lock.
for file in [BUCKET_METADATA_FILE, BUCKET_INCARNATION_FILE] {
let key = format!("{BUCKET_META_PREFIX}/{bucket}/{file}");
let namespace = store.pools[0].disk_set[0]
.new_ns_lock(RUSTFS_META_BUCKET, &key)
.await
.expect("resolve persisted identity namespace");
drop(
namespace
.get_write_lock(Duration::from_secs(30))
.await
.expect("identity write tail drained"),
);
}
}
async fn dedicated_bucket(store: &ECStore, bucket: &str) {
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some(PREFIX.to_owned()),
// Creation must enforce versioning without relying on the request flag.
versioning_enabled: false,
..Default::default()
},
)
.await
.expect("create a dedicated artifact bucket on real disks");
}
async fn persisted(store: &Arc<ECStore>, bucket: &str) -> BucketMetadata {
let (metadata, exists) = load_bucket_metadata_parse_with_presence(store.clone(), bucket, true)
.await
.expect("read actual persisted bucket metadata");
assert!(exists, "ARTIFACT_METADATA_PERSISTED");
metadata
}
fn assert_namespace(metadata: &BucketMetadata, incarnation: Uuid) {
assert_eq!(metadata.artifact_base_prefix.as_deref(), Some(PREFIX), "ARTIFACT_NAMESPACE_IMMUTABLE");
assert_eq!(metadata.bucket_incarnation_id, incarnation, "ARTIFACT_INCARNATION_IMMUTABLE");
assert!(
metadata.versioning_config.as_ref().is_some_and(VersioningApi::enabled),
"ARTIFACT_VERSIONING_REQUIRED"
);
}
async fn assert_persisted_and_cached(store: &Arc<ECStore>, bucket: &str, incarnation: Uuid) {
assert_namespace(&persisted(store, bucket).await, incarnation);
let cached = metadata_sys::get_in(&store.ctx, bucket)
.await
.expect("read cached bucket identity");
assert_namespace(&cached, incarnation);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_creation_enables_versioning_and_survives_store_reopen() {
let root = tempfile::tempdir().expect("artifact bucket store directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-created-reopen";
dedicated_bucket(&store, bucket).await;
let created = persisted(&store, bucket).await;
let incarnation = created.bucket_incarnation_id;
assert!(!incarnation.is_nil(), "ARTIFACT_INCARNATION_REQUIRED");
assert!(created.bucket_creation_committed, "ARTIFACT_CREATION_COMMITTED");
assert_namespace(&created, incarnation);
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
metadata_sys::reload_bucket_metadata(store.clone(), bucket)
.await
.expect("load the reopened bucket from persisted metadata");
assert_persisted_and_cached(&store, bucket, incarnation).await;
store
.make_bucket(
bucket,
&MakeBucketOptions {
force_create: true,
..Default::default()
},
)
.await
.expect("ordinary force-create replay preserves the dedicated namespace");
assert_persisted_and_cached(&store, bucket, incarnation).await;
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_ordinary_configuration_update_preserves_namespace() {
let root = tempfile::tempdir().expect("artifact configuration store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-config-update";
dedicated_bucket(&store, bucket).await;
let incarnation = persisted(&store, bucket).await.bucket_incarnation_id;
metadata_sys::update_in(&store.ctx, bucket, BUCKET_TAGGING_CONFIG, TAGGING.to_vec())
.await
.expect("ordinary tagging update remains supported");
let changed = persisted(&store, bucket).await;
assert_eq!(changed.tagging_config_xml, TAGGING, "ARTIFACT_ORDINARY_CONFIG_UPDATED");
assert_persisted_and_cached(&store, bucket, incarnation).await;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
let suspended = crate::bucket::utils::deserialize(SUSPENDED).expect("valid suspended versioning XML");
for stale_typed_config in [None, Some(suspended)] {
let mut replacement = changed.clone();
replacement.versioning_config = stale_typed_config;
sys.set_validated(replacement)
.await
.expect("validated cache setter reparses authoritative XML");
assert_persisted_and_cached(&store, bucket, incarnation).await;
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_create_rejects_existing_bucket_type_and_prefix_changes() {
let root = tempfile::tempdir().expect("artifact immutable creation store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let ordinary = "artifact-existing-ordinary";
store
.make_bucket(ordinary, &MakeBucketOptions::default())
.await
.expect("create ordinary bucket");
let original = persisted(&store, ordinary).await;
assert!(original.artifact_base_prefix.is_none());
for force_create in [false, true] {
let result = store
.make_bucket(
ordinary,
&MakeBucketOptions {
artifact_base_prefix: Some(PREFIX.to_owned()),
force_create,
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_NAMESPACE_IMMUTABLE: ordinary bucket cannot change type");
let unchanged = persisted(&store, ordinary).await;
assert_eq!(unchanged.artifact_base_prefix, None);
assert_eq!(unchanged.bucket_incarnation_id, original.bucket_incarnation_id);
assert_eq!(unchanged.versioning_config_xml, original.versioning_config_xml);
}
let dedicated = "artifact-existing-dedicated";
dedicated_bucket(&store, dedicated).await;
let incarnation = persisted(&store, dedicated).await.bucket_incarnation_id;
for force_create in [false, true] {
let result = store
.make_bucket(
dedicated,
&MakeBucketOptions {
artifact_base_prefix: Some("another/".to_owned()),
force_create,
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_NAMESPACE_IMMUTABLE: create cannot replace an existing prefix");
assert_persisted_and_cached(&store, dedicated, incarnation).await;
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_full_metadata_replacement_cannot_change_namespace() {
let root = tempfile::tempdir().expect("artifact metadata replacement store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let dedicated = "artifact-replacement-dedicated";
let ordinary = "artifact-replacement-ordinary";
dedicated_bucket(&store, dedicated).await;
store
.make_bucket(ordinary, &MakeBucketOptions::default())
.await
.expect("create ordinary replacement fixture");
let original = persisted(&store, dedicated).await;
let incarnation = original.bucket_incarnation_id;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
for route in 0..3 {
for prefix in [None, Some("replacement/".to_owned())] {
let mut replacement = original.clone();
replacement.artifact_base_prefix = prefix;
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
_ => sys.set_validated(replacement).await,
};
assert!(
result.is_err(),
"ARTIFACT_NAMESPACE_IMMUTABLE: complete replacement cannot remove or change prefix"
);
assert_persisted_and_cached(&store, dedicated, incarnation).await;
}
let mut replacement = persisted(&store, ordinary).await;
let ordinary_incarnation = replacement.bucket_incarnation_id;
replacement.artifact_base_prefix = Some(PREFIX.to_owned());
replacement.versioning_config_xml = ENABLED.to_vec();
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
_ => sys.set_validated(replacement).await,
};
assert!(
result.is_err(),
"ARTIFACT_NAMESPACE_IMMUTABLE: complete replacement cannot promote ordinary bucket"
);
let unchanged = persisted(&store, ordinary).await;
assert!(unchanged.artifact_base_prefix.is_none());
assert_eq!(unchanged.bucket_incarnation_id, ordinary_incarnation);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_versioning_cannot_be_suspended_or_removed() {
let root = tempfile::tempdir().expect("artifact mandatory versioning store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-required-versioning";
dedicated_bucket(&store, bucket).await;
let original = persisted(&store, bucket).await;
let incarnation = original.bucket_incarnation_id;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
for xml in [SUSPENDED, b"".as_slice(), EXCLUDED_PREFIX, EXCLUDED_FOLDERS] {
let result = metadata_sys::update_in(&store.ctx, bucket, BUCKET_VERSIONING_CONFIG, xml.to_vec()).await;
assert!(
result.is_err(),
"ARTIFACT_VERSIONING_REQUIRED: configuration update cannot disable versioning"
);
assert_persisted_and_cached(&store, bucket, incarnation).await;
for route in 0..3 {
let mut replacement = original.clone();
replacement.versioning_config_xml = xml.to_vec();
replacement.versioning_config = None;
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
_ => sys.set_validated(replacement).await,
};
assert!(
result.is_err(),
"ARTIFACT_VERSIONING_REQUIRED: complete replacement cannot disable versioning"
);
assert_persisted_and_cached(&store, bucket, incarnation).await;
}
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_pending_creation_reopen_retry_preserves_namespace() {
let root = tempfile::tempdir().expect("artifact pending creation store directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-pending-retry";
let mut intent = BucketMetadata::new_with_default_durability(bucket);
intent.artifact_base_prefix = Some(PREFIX.to_owned());
intent.versioning_config_xml = ENABLED.to_vec();
let incarnation = intent.bucket_incarnation_id;
// Persist through the production intent writer, before any physical bucket exists.
metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("persist real creation intent");
let pending = persisted(&store, bucket).await;
assert!(!pending.bucket_creation_committed, "ARTIFACT_CREATION_PENDING");
assert_namespace(&pending, incarnation);
for disk in 0..4 {
assert!(
!root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).exists(),
"ARTIFACT_PHYSICAL_BUCKET_NOT_CREATED"
);
}
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
let result = store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some("replacement/".to_owned()),
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_NAMESPACE_IMMUTABLE: pending intent rejects a changed request");
let pending = persisted(&store, bucket).await;
assert_namespace(&pending, incarnation);
assert!(!pending.bucket_creation_committed);
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("retry resumes persisted creation identity");
let completed = persisted(&store, bucket).await;
assert!(completed.bucket_creation_committed, "ARTIFACT_CREATION_COMMITTED");
assert_persisted_and_cached(&store, bucket, incarnation).await;
for disk in 0..4 {
assert!(
root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).is_dir(),
"ARTIFACT_PHYSICAL_BUCKET_CREATED"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_partial_volume_retry_preserves_pending_identity() {
let root = tempfile::tempdir().expect("artifact partial volume store directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-partial-volume";
let mut intent = BucketMetadata::new_with_default_durability(bucket);
intent.artifact_base_prefix = Some(PREFIX.to_owned());
intent.versioning_config_xml = ENABLED.to_vec();
let incarnation = intent.bucket_incarnation_id;
metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("persist real creation intent before a partial physical creation");
let disks = store.pools[0].disk_set[0].disk_inventory().await;
disks[0]
.as_ref()
.expect("first real disk")
.make_volume(bucket)
.await
.expect("create only one physical volume");
assert!(root.path().join(format!("pool0/set0/disk0/{bucket}")).is_dir());
for disk in 1..4 {
assert!(!root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).exists());
}
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect_err("partial physical creation requires explicit recovery");
let pending = persisted(&store, bucket).await;
assert_namespace(&pending, incarnation);
assert!(!pending.bucket_creation_committed, "ARTIFACT_PARTIAL_CREATION_STAYS_PENDING");
for disk in 1..4 {
assert!(
!root.path().join(format!("pool0/set0/disk{disk}/{bucket}")).exists(),
"ARTIFACT_PARTIAL_CREATION_NOT_SILENTLY_COMPLETED"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_metadata_replacement_cannot_spoof_incarnation() {
let root = tempfile::tempdir().expect("artifact spoofed incarnation store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-spoofed-incarnation";
dedicated_bucket(&store, bucket).await;
let original = persisted(&store, bucket).await;
let incarnation = original.bucket_incarnation_id;
let sys = store.ctx.bucket_metadata_sys().expect("instance metadata system");
let sys = sys.read().await.clone();
for route in 0..4 {
for spoofed in [Uuid::nil(), Uuid::new_v4()] {
for (prefix, xml) in [
(None, b"".as_slice()),
(None, SUSPENDED),
(Some("replacement/"), ENABLED),
(Some(PREFIX), ENABLED),
] {
let mut replacement = original.clone();
replacement.bucket_incarnation_id = spoofed;
replacement.artifact_base_prefix = prefix.map(str::to_owned);
replacement.versioning_config_xml = xml.to_vec();
replacement.versioning_config = None;
let result = match route {
0 => metadata_sys::set_bucket_metadata_in(&store.ctx, replacement).await,
1 => sys.persist_and_set(replacement).await,
2 => sys.set_validated(replacement).await,
_ => replacement.save_with_store(store.clone()).await,
};
assert!(
result.is_err(),
"ARTIFACT_INCARNATION_IMMUTABLE: route={route}, spoofed={spoofed}, prefix={prefix:?}"
);
assert_persisted_and_cached(&store, bucket, incarnation).await;
}
}
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_real_delete_recreate_allows_new_namespace_and_incarnation() {
let root = tempfile::tempdir().expect("artifact genuine recreation store directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-genuine-recreation";
dedicated_bucket(&store, bucket).await;
let mut incarnation = persisted(&store, bucket).await.bucket_incarnation_id;
for prefix in [Some("replacement/"), None] {
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete the empty bucket through production storage");
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: prefix.map(str::to_owned),
..Default::default()
},
)
.await
.expect("genuine recreation may choose a new bucket namespace");
let recreated = persisted(&store, bucket).await;
assert!(!recreated.bucket_incarnation_id.is_nil(), "ARTIFACT_INCARNATION_REQUIRED");
assert_ne!(recreated.bucket_incarnation_id, incarnation, "ARTIFACT_RECREATION_NEW_INCARNATION");
assert_eq!(recreated.artifact_base_prefix.as_deref(), prefix, "ARTIFACT_RECREATION_NEW_NAMESPACE");
assert_eq!(recreated.versioning_config.as_ref().is_some_and(VersioningApi::enabled), prefix.is_some());
assert!(recreated.bucket_creation_committed, "ARTIFACT_CREATION_COMMITTED");
let cached = metadata_sys::get_in(&store.ctx, bucket)
.await
.expect("recreated identity cached");
assert_eq!(cached.bucket_incarnation_id, recreated.bucket_incarnation_id);
assert_eq!(cached.artifact_base_prefix.as_deref(), prefix);
incarnation = recreated.bucket_incarnation_id;
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial]
async fn artifact_bucket_create_observes_current_generation() {
let root = tempfile::tempdir().expect("creation observation storage");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-create-observation";
dedicated_bucket(&store, bucket).await;
let initial = persisted(&store, bucket).await.bucket_incarnation_id;
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete before observation");
dedicated_bucket(&store, bucket).await;
let current = persisted(&store, bucket).await.bucket_incarnation_id;
assert_ne!(initial, current);
// Invoke the exact observation phase consumed by make_artifact_bucket,
// after real replacement between its creation and observation phases.
assert_eq!(
store
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect("observe replacement"),
current,
"ARTIFACT_CREATE_CURRENT_GENERATION"
);
assert!(
store.observe_artifact_bucket_incarnation(bucket, "other/").await.is_err(),
"ARTIFACT_CREATE_CURRENT_GENERATION: mismatched namespace cannot produce a receipt"
);
let attempt = format!("{PREFIX}61/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, initial, &attempt)
.await
.is_err(),
"ARTIFACT_CREATE_CURRENT_GENERATION: old incarnation cannot seal same prefix"
);
store
.clone()
.seal_artifact_attempt_puts(bucket, current, &attempt)
.await
.expect("current identity seals");
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
let (_ctx, reopened, stop) = open_store(root.path()).await;
assert_eq!(
reopened
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect("reopen observed identity"),
current,
"ARTIFACT_CREATE_CURRENT_GENERATION"
);
stop.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial]
async fn artifact_bucket_create_observation_rejects_corrupt_authority() {
use crate::{
config::com::{read_config, save_config_with_opts},
object_api::{ObjectOptions, WriteCompletion},
};
let root = tempfile::tempdir().expect("creation observation faults");
let (mut ctx, mut store, mut shutdown) = open_store(root.path()).await;
let bucket = "artifact-create-corruption";
let incarnation = store
.clone()
.make_artifact_bucket(bucket, PREFIX)
.await
.expect("actual create and observation");
let original = persisted(&store, bucket).await;
let sys = ctx.bucket_metadata_sys().expect("actual metadata authority");
// These faults target the authoritative cache branch actually read by the
// observation. Public metadata updates reject them before publication.
for fault in 0..6 {
let mut damaged = original.clone();
match fault {
0 => damaged.name = "wrong-bucket".into(),
1 => damaged.artifact_base_prefix = Some("other/".into()),
2 => damaged.versioning_config = None,
3 => damaged.versioning_config.as_mut().expect("versioning").exclude_folders = Some(true),
4 => damaged.versioning_config = Some(crate::bucket::utils::deserialize(SUSPENDED).expect("suspended XML")),
_ => {
damaged.versioning_config = Some(crate::bucket::utils::deserialize(EXCLUDED_PREFIX).expect("excluded prefix XML"))
}
}
sys.read().await.set_for_test(bucket.into(), Arc::new(damaged)).await;
assert!(
store.observe_artifact_bucket_incarnation(bucket, PREFIX).await.is_err(),
"ARTIFACT_CREATE_CURRENT_GENERATION: corrupt cached authority fault={fault}"
);
sys.read().await.set_for_test(bucket.into(), Arc::new(original.clone())).await;
}
drop(sys);
for (file, corrupt, expected_error) in [
(BUCKET_METADATA_FILE, b"corrupt metadata".to_vec(), "read_bucket_metadata: format invalid"),
(
BUCKET_INCARNATION_FILE,
Uuid::nil().as_bytes().to_vec(),
"persisted bucket incarnation is nil",
),
] {
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{file}");
let saved = read_config(store.clone(), &path).await.expect("real persisted record");
let opts = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
};
save_config_with_opts(store.clone(), &path, corrupt, &opts)
.await
.expect("inject physical record fault");
// Reopen with a fresh metadata authority so observation must read the
// actual damaged record, without a cached value or a negative entry.
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
(ctx, store, shutdown) = open_store(root.path()).await;
let error = store
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect_err("physical corruption cannot produce a receipt");
assert!(
error.to_string().contains(expected_error),
"ARTIFACT_CREATE_CURRENT_GENERATION: corrupt physical authority {file}: {error}"
);
save_config_with_opts(store.clone(), &path, saved, &opts)
.await
.expect("restore physical record");
drain_before_reopen(&store, &shutdown, bucket).await;
drop(store);
drop(ctx);
(ctx, store, shutdown) = open_store(root.path()).await;
assert_eq!(
store
.observe_artifact_bucket_incarnation(bucket, PREFIX)
.await
.expect("restored observation"),
incarnation
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_create_requires_namespace_locking_before_creation() {
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some("false"))], async {
let root = tempfile::tempdir().expect("disabled lock storage");
let (ctx, store, shutdown) = open_store(root.path()).await;
assert!(ctx.lock_manager().is_disabled());
let bucket = "artifact-create-no-lock";
let error = store
.clone()
.make_artifact_bucket(bucket, PREFIX)
.await
.expect_err("creation requires real locks");
assert!(
error
.to_string()
.contains("artifact bucket creation requires namespace locking")
);
assert!(
store
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.is_err(),
"ARTIFACT_CREATE_LOCK_REQUIRED: rejection precedes physical creation"
);
shutdown.cancel();
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_inspect_reopens_without_migrating_other_buckets() {
use crate::config::com::read_config;
let root = tempfile::tempdir().expect("inspect storage");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-inspect-reopen";
let incarnation = store
.clone()
.make_artifact_bucket(bucket, PREFIX)
.await
.expect("managed bucket");
let pending = "artifact-inspect-pending";
let mut intent = BucketMetadata::new_with_default_durability(pending);
intent.artifact_base_prefix = Some(PREFIX.to_owned());
intent.versioning_config_xml = ENABLED.to_vec();
metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
.await
.expect("real pending intent");
// Physical quorum exists: a migrating reader could silently complete this intent.
let legacy = "artifact-inspect-legacy";
for disk in store.pools[0].disk_set[0].disk_inventory().await.into_iter().flatten() {
for name in [legacy, pending] {
disk.make_volume(name).await.expect("real preexisting volume");
}
}
let pending_path = format!("{BUCKET_META_PREFIX}/{pending}/{BUCKET_METADATA_FILE}");
let before = read_config(store.clone(), &pending_path).await.expect("pending bytes");
drain_before_reopen(&store, &shutdown, bucket).await;
drain_before_reopen(&store, &shutdown, pending).await;
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
assert_eq!(
store
.inspect_artifact_bucket(bucket, PREFIX)
.await
.expect("fresh-instance inspection"),
incarnation
);
for name in [legacy, pending, "artifact-inspect-absent"] {
assert!(store.inspect_artifact_bucket(name, PREFIX).await.is_err(), "ARTIFACT_INSPECT_READ_ONLY");
}
assert_eq!(
read_config(store.clone(), &pending_path).await.expect("pending remains"),
before,
"ARTIFACT_INSPECT_READ_ONLY"
);
assert!(!persisted(&store, pending).await.bucket_creation_committed);
for name in [legacy, "artifact-inspect-absent"] {
for file in [BUCKET_METADATA_FILE, BUCKET_INCARNATION_FILE] {
let path = format!("{BUCKET_META_PREFIX}/{name}/{file}");
assert!(
read_config(store.clone(), &path).await.is_err(),
"ARTIFACT_INSPECT_READ_ONLY: no migration record"
);
}
}
for disk in 0..4 {
assert!(
!root
.path()
.join(format!("pool0/set0/disk{disk}/artifact-inspect-absent"))
.exists()
);
}
assert!(store.inspect_artifact_bucket(bucket, "wrong/").await.is_err());
assert_eq!(store.inspect_artifact_bucket(bucket, PREFIX).await.unwrap(), incarnation);
store
.ctx
.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
.await;
let error = store
.inspect_artifact_bucket(bucket, PREFIX)
.await
.expect_err("file endpoints cannot use distributed lock mode");
assert!(
error
.to_string()
.contains("artifact PUT sealing requires local storage or a distributed Unix URL topology")
);
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_bucket_inspect_requires_namespace_locking() {
temp_env::async_with_vars([("RUSTFS_LOCK_ENABLED", Some("false"))], async {
let root = tempfile::tempdir().expect("disabled inspect locking");
let (ctx, store, shutdown) = open_store(root.path()).await;
assert!(ctx.lock_manager().is_disabled());
let error = store
.inspect_artifact_bucket("artifact-inspect-no-lock", PREFIX)
.await
.expect_err("locking required before reading");
assert!(
error
.to_string()
.contains("artifact bucket inspection requires namespace locking")
);
shutdown.cancel();
})
.await;
}
@@ -1,398 +0,0 @@
#![cfg(unix)]
use super::{build_isolated_test_store, run_large_stack_async_test, without_storage_class_env};
use crate::{
bucket::metadata_sys,
disk::{Disk, local::artifact_inventory::ArtifactInventoryFrame},
object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode, WriteCompletion},
storage_api_contracts::{
bucket::{BucketOperations as _, MakeBucketOptions},
object::ObjectIO as _,
},
};
use std::time::Duration;
use uuid::Uuid;
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_managed_put_zero_and_versions_are_complete() {
assert_managed_put_inventory_is_complete(ShardIntegrityWriteMode::Legacy);
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_protected_managed_put_versions_are_complete() {
assert_managed_put_inventory_is_complete(ShardIntegrityWriteMode::Protected);
}
fn assert_managed_put_inventory_is_complete(mode: ShardIntegrityWriteMode) {
run_large_stack_async_test("artifact-inventory-managed", move || async move {
let root = tempfile::tempdir().expect("managed inventory disk roots");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(root.path(), "inventory-managed", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let bucket = "artifact-inventory-managed";
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some("outputs/".to_owned()),
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("actual managed versioned bucket");
let incarnation = store.bucket_incarnation_id(bucket).await.expect("actual incarnation");
let prefix = format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
let keys = vec![
(format!("{prefix}zero"), Vec::new()),
(format!("{prefix}inline"), vec![0xA5; 1024]),
(format!("{prefix}payload"), vec![0xA5; 1024 * 1024]),
];
let mut expected = std::collections::BTreeSet::new();
for (key, data) in &keys {
let object = store
.put_object(
bucket,
key,
&mut PutObjReader::from_vec(data.clone()),
&ObjectOptions {
versioned: true,
shard_integrity_write_mode: Some(mode),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("real managed PUT including empty, inline and sharded payloads");
expected.insert((key.clone(), object.version_id.expect("exact managed version")));
}
let identity = store
.artifact_receiver_identity(bucket, incarnation, &prefix, true)
.await
.expect("metadata-derived identity");
store
.clone()
.seal_artifact_attempt_writes(bucket, incarnation, &prefix)
.await
.expect("actual all-writes seal");
for member in store.pools[0].disk_set[0].disk_inventory().await {
let member = member.expect("configured local member");
let Disk::Local(local) = member.as_ref() else {
panic!("local fixture");
};
let local = local.get_disk();
let id = local.artifact_disk_id().await.expect("fresh actual disk identity");
let (output, mut input) = tokio::sync::mpsc::channel(2);
let (result, frames) = tokio::join!(
local.inventory_artifact_attempt(
id,
bucket,
&prefix,
identity,
tokio::time::Instant::now() + Duration::from_secs(30),
output
),
async {
let mut frames = Vec::new();
while let Some(frame) = input.recv().await {
frames.push(frame);
}
frames
}
);
assert!(result.is_ok(), "A03I1_REAL_MANAGED_PUT_SCAN: {result:?}");
let actual = frames
.iter()
.filter_map(|frame| match frame {
ArtifactInventoryFrame::Entries { versions, .. } => Some(versions),
_ => None,
})
.flatten()
.map(|version| (version.key.clone(), version.version_id))
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(actual, expected, "A03I1_REAL_ZERO_INLINE_AND_SHARDED_VERSIONS");
assert!(matches!(frames.last(), Some(ArtifactInventoryFrame::Complete { entries: 3, .. })));
}
shutdown.cancel();
});
}
async fn inventory_fixture() -> (
tempfile::TempDir,
std::sync::Arc<crate::store::ECStore>,
tokio_util::sync::CancellationToken,
Uuid,
String,
) {
let root = tempfile::tempdir().expect("inventory aggregate roots");
let (_ctx, store, shutdown) =
without_storage_class_env(build_isolated_test_store(root.path(), "inventory-aggregate", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
store
.make_bucket(
"inventory-aggregate",
&MakeBucketOptions {
artifact_base_prefix: Some("outputs/".into()),
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("managed bucket");
let incarnation = store
.bucket_incarnation_id("inventory-aggregate")
.await
.expect("actual incarnation");
let prefix = format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
store
.put_object(
"inventory-aggregate",
&format!("{prefix}object"),
&mut PutObjReader::from_vec(vec![1; 1024]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("actual managed object");
store
.clone()
.seal_artifact_attempt_writes("inventory-aggregate", incarnation, &prefix)
.await
.expect("actual two-domain seal");
(root, store, shutdown, incarnation, prefix)
}
async fn aggregate(
store: std::sync::Arc<crate::store::ECStore>,
incarnation: Uuid,
prefix: &str,
) -> (
crate::error::Result<()>,
Vec<crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame>,
) {
let (output, mut receiver) = tokio::sync::mpsc::channel(1);
let drain = async {
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
tokio::join!(
store.inventory_artifact_attempt(
"inventory-aggregate",
incarnation,
prefix,
tokio::time::Instant::now() + Duration::from_secs(30),
output
),
drain
)
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_all_members_required() {
run_large_stack_async_test("artifact-inventory-all-members", || async {
use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
let (_root, store, shutdown, incarnation, prefix) = inventory_fixture().await;
let (result, frames) = aggregate(store.clone(), incarnation, &prefix).await;
result.expect("all four real sealed disks scanned");
let observed: std::collections::BTreeSet<_> = frames
.iter()
.filter_map(|frame| match frame {
ArtifactAttemptInventoryFrame::Observations {
endpoint,
disk_id,
versions,
} => {
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].key, format!("{prefix}object"));
Some((endpoint.clone(), *disk_id))
}
_ => None,
})
.collect();
assert_eq!(observed.len(), 4, "A03I2_ALL_MEMBERS");
assert!(
matches!(
frames.last(),
Some(ArtifactAttemptInventoryFrame::Complete {
members: 4,
observations: 4
})
),
"A03I2_ALL_MEMBERS"
);
let original = store.pools[0].disk_set[0].disks.write().await[3].take();
let (denied, frames) = aggregate(store.clone(), incarnation, &prefix).await;
assert!(denied.is_err(), "one missing configured member is incomplete");
assert!(
!frames
.iter()
.any(|frame| matches!(frame, ArtifactAttemptInventoryFrame::Complete { .. }))
);
store.pools[0].disk_set[0].disks.write().await[3] = original;
shutdown.cancel();
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_membership_change_is_incomplete() {
run_large_stack_async_test("artifact-inventory-membership", || async {
use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
let (_root, store, shutdown, incarnation, prefix) = inventory_fixture().await;
let original = store.pools[0].disk_set[0].disk_inventory().await[0]
.clone()
.expect("first member");
let Disk::Local(local) = original.as_ref() else {
panic!("local fixture");
};
let replacement = std::sync::Arc::new(Disk::Local(local.clone()));
let (output, mut receiver) = tokio::sync::mpsc::channel(1);
let owned = store.clone();
let worker = tokio::spawn(async move {
owned
.inventory_artifact_attempt(
"inventory-aggregate",
incarnation,
&prefix,
tokio::time::Instant::now() + Duration::from_secs(30),
output,
)
.await
});
assert!(matches!(receiver.recv().await, Some(ArtifactAttemptInventoryFrame::Observations { .. })));
store.pools[0].disk_set[0].disks.write().await[0] = Some(replacement);
let mut complete = false;
while let Some(frame) = receiver.recv().await {
complete |= matches!(frame, ArtifactAttemptInventoryFrame::Complete { .. });
}
let error = worker
.await
.expect("aggregate worker")
.expect_err("same endpoint/UUID but changed captured Arc is incomplete");
assert!(error.to_string().contains("membership changed"), "{error}");
assert!(!complete);
store.pools[0].disk_set[0].disks.write().await[0] = Some(original);
shutdown.cancel();
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_inventory_readonly_authority_cold_cache_and_queued_writer() {
run_large_stack_async_test("artifact-inventory-readonly-authority", || async {
use crate::{
disk::{BUCKET_META_PREFIX, DiskAPI as _, RUSTFS_META_BUCKET},
storage_api_contracts::namespace::NamespaceLocking as _,
};
let (_root, store, shutdown, incarnation, prefix) = inventory_fixture().await;
let member = store.pools[0].disk_set[0].disk_inventory().await[0]
.clone()
.expect("real receiver");
let Disk::Local(local) = member.as_ref() else {
panic!("local");
};
let expected = local.artifact_disk_id().await.expect("actual disk");
let _lifecycle = store
.acquire_bucket_lifecycle_read_lock("inventory-aggregate")
.await
.expect("coordinator lifecycle owner");
metadata_sys::remove_bucket_metadata_in(&store.ctx, "inventory-aggregate")
.await
.expect("cold metadata cache");
let lock = store
.new_ns_lock("inventory-aggregate", "inventory-aggregate")
.await
.expect("business bucket namespace");
let reader = lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await
.expect("existing namespace reader");
let mut writer = Box::pin(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()));
assert!(
matches!(futures::poll!(writer.as_mut()), std::task::Poll::Pending),
"real queued business bucket WRITE"
);
let (output, mut frames) = tokio::sync::mpsc::channel(1);
let drain = async { while frames.recv().await.is_some() {} };
let endpoint = member.endpoint().to_string();
let receive = store.inventory_local_artifact_attempt(
(&endpoint, expected),
("inventory-aggregate", incarnation),
&prefix,
rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase::ArtifactInventoryScan,
tokio::time::Instant::now() + Duration::from_secs(2),
output,
);
let (result, ()) = tokio::join!(receive, drain);
result.expect("A03I2_READONLY_AUTHORITY_NO_REENTRY");
drop(writer);
drop(reader);
drop(_lifecycle);
// A legacy physical bucket lacking both authoritative records must
// stay legacy after rejected inventory; no implicit getter migration.
store
.make_bucket(
"inventory-legacy",
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("ordinary legacy fixture");
let members = store.pools[0].disk_set[0].disk_inventory().await;
for member in members.iter().flatten() {
for file in [".metadata.bin", ".bucket-incarnation"] {
let path = member
.path()
.join(RUSTFS_META_BUCKET)
.join(BUCKET_META_PREFIX)
.join("inventory-legacy")
.join(file);
if path.exists() {
std::fs::remove_dir_all(&path).expect("remove fixture-only creation facts");
}
}
}
metadata_sys::remove_bucket_metadata_in(&store.ctx, "inventory-legacy")
.await
.expect("cold legacy cache");
let (output, mut frames) = tokio::sync::mpsc::channel(1);
let drain = async { while frames.recv().await.is_some() {} };
let (denied, ()) = tokio::join!(
store.clone().inventory_artifact_attempt(
"inventory-legacy",
Uuid::new_v4(),
&prefix,
tokio::time::Instant::now() + Duration::from_secs(2),
output
),
drain
);
assert!(denied.is_err());
for member in members.iter().flatten() {
for file in [".metadata.bin", ".bucket-incarnation"] {
assert!(
!member
.path()
.join(RUSTFS_META_BUCKET)
.join(BUCKET_META_PREFIX)
.join("inventory-legacy")
.join(file)
.exists(),
"A03I2_READONLY_NO_MIGRATION"
);
}
}
shutdown.cancel();
});
}
@@ -1,587 +0,0 @@
use super::{build_isolated_test_store, without_storage_class_env};
use crate::{
bucket::{artifact::artifact_put_seal_record_path, metadata_sys},
config::com,
disk::{DiskAPI as _, RUSTFS_META_BUCKET},
object_api::{ObjectInfo, ObjectOptions, PutObjReader, WriteCompletion},
runtime::instance::InstanceContext,
set_disk::{PutObjectCommitBarrier, PutObjectCommitPause, rename_fanout_barrier},
storage_api_contracts::{
bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions},
namespace::NamespaceLocking as _,
object::ObjectIO as _,
},
store::ECStore,
};
use std::{sync::Arc, time::Duration};
use tokio::io::AsyncReadExt as _;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const DEADLINE: Duration = Duration::from_secs(30);
fn attempt_prefix() -> String {
format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4())
}
async fn open_store(root: &std::path::Path) -> (Arc<InstanceContext>, Arc<ECStore>, CancellationToken) {
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root, "artifact-put-seal", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(ctx, store, shutdown)
}
async fn create_bucket(store: &ECStore, bucket: &str) -> Uuid {
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: Some("outputs/".to_owned()),
..Default::default()
},
)
.await
.expect("create a real versioned artifact bucket");
store
.bucket_incarnation_id(bucket)
.await
.expect("read actual bucket incarnation")
}
async fn put(store: &ECStore, bucket: &str, object: &str) -> ObjectInfo {
store
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0xA5; 273]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("unsealed PUT must commit on all four disks")
}
async fn disk_metadata(store: &ECStore, bucket: &str, object: &str) -> Vec<Vec<u8>> {
let disks = store.pools[0].disk_set[0].disk_inventory().await;
assert_eq!(disks.len(), 4);
let mut metadata = Vec::new();
for disk in disks {
metadata.push(
disk.expect("real fixture disk is online")
.read_xl(bucket, object, false)
.await
.expect("read actual per-disk versions")
.buf,
);
}
metadata
}
async fn assert_rejected_without_new_version(store: &ECStore, bucket: &str, object: &str, before: &[Vec<u8>]) {
let result = store
.put_object(
bucket,
object,
&mut PutObjReader::from_vec(vec![0xBB; 379]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await;
assert!(result.is_err(), "ARTIFACT_PUT_SEALED: later PUT must be rejected");
assert_eq!(
disk_metadata(store, bucket, object).await,
before,
"ARTIFACT_PUT_SEALED: no disk gains a version"
);
}
async fn tail_barrier_case(cancel_put_waiter: bool) {
let root = tempfile::tempdir().expect("artifact tail fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-tail";
let incarnation = create_bucket(&store, bucket).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
let tracker = rename_fanout_barrier::observe_tasks(&object);
let tail = rename_fanout_barrier::arm(&object, 0, rename_fanout_barrier::PHASE_RENAME);
let quorum = PutObjectCommitBarrier::install(bucket, &object, PutObjectCommitPause::AfterRenameQuorum);
let handoff = PutObjectCommitBarrier::install(bucket, &object, PutObjectCommitPause::AfterRenameHandoff);
let put_store = store.clone();
let put_object = object.clone();
let put_task = tokio::spawn(async move {
put_store
.put_object(
bucket,
&put_object,
&mut PutObjReader::from_vec(vec![0xA5; 273]),
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
});
tokio::time::timeout(DEADLINE, tail.wait_until_paused())
.await
.expect("one real disk pauses before rename");
quorum.wait_until_paused().await;
if cancel_put_waiter {
put_task.abort();
}
quorum.release();
handoff.wait_until_paused().await;
handoff.release();
let result = tokio::time::timeout(DEADLINE, put_task)
.await
.expect("PUT waiter returns while its tail remains paused");
if cancel_put_waiter {
assert!(result.expect_err("cancelled PUT waiter").is_cancelled());
} else {
result
.expect("PUT task joins")
.expect("PUT quorum ACK succeeds before tail completion");
}
assert!(tracker.running() > 0, "real rename tail must still be live");
let parallel_object = format!("{prefix}parallel.png");
let parallel = tokio::time::timeout(DEADLINE, put(&store, bucket, &parallel_object))
.await
.expect("ARTIFACT_PUT_SHARED_ADMISSION: another object in the same attempt completes while A is paused");
let mut reader = tokio::time::timeout(
DEADLINE,
store.get_object_reader(
bucket,
&parallel_object,
None,
Default::default(),
&ObjectOptions {
version_id: Some(parallel.version_id.expect("parallel PUT has a real version").to_string()),
..Default::default()
},
),
)
.await
.expect("parallel object GET completes while A is paused")
.expect("parallel object is readable");
let mut bytes = Vec::new();
tokio::time::timeout(DEADLINE, reader.stream.read_to_end(&mut bytes))
.await
.expect("parallel object body completes while A is paused")
.expect("read parallel object body");
assert_eq!(bytes, vec![0xA5; 273], "ARTIFACT_PUT_SHARED_ADMISSION: B publishes exact bytes");
drop(reader);
assert!(tracker.running() > 0, "A's physical tail remains paused throughout B's PUT and GET");
let record = artifact_put_seal_record_path(bucket, incarnation, &prefix);
let attempt_lock = store
.new_ns_lock(RUSTFS_META_BUCKET, &record)
.await
.expect("resolve the actual PUT and seal coordination namespace");
assert!(
attempt_lock.get_write_lock(Duration::from_secs(1)).await.is_err(),
"ARTIFACT_PUT_TAIL_BARRIER: the real commit owner retains the attempt lock until its disk tail drains"
);
let seal_store = store.clone();
let seal_prefix = prefix.clone();
let mut seal =
tokio::spawn(async move { seal_store.seal_artifact_attempt_puts(bucket, incarnation, &seal_prefix).await });
assert!(
tokio::time::timeout(Duration::from_millis(200), &mut seal).await.is_err(),
"ARTIFACT_PUT_TAIL_BARRIER: seal cannot finish while a real rename tail is paused"
);
assert!(tracker.running() > 0, "tail remains paused throughout the seal attempt");
tail.release();
tokio::time::timeout(DEADLINE, seal)
.await
.expect("seal resumes after tail release")
.expect("seal task joins")
.expect("seal persists");
tokio::time::timeout(DEADLINE, async {
while tracker.running() != 0 || ctx.namespace_commits_pending() {
tokio::task::yield_now().await;
}
})
.await
.expect("actual detached write owners drain");
let before = disk_metadata(&store, bucket, &object).await;
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
})
.await;
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_waits_for_quorum_ack_rename_tail() {
tail_barrier_case(false).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_waits_after_put_waiter_cancellation() {
tail_barrier_case(true).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_survives_reopen_and_is_attempt_scoped() {
let root = tempfile::tempdir().expect("artifact durable seal directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-reopen";
let incarnation = create_bucket(&store, bucket).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let before = disk_metadata(&store, bucket, &object).await;
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect("persist first seal");
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect("same seal is idempotent");
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
put(&store, bucket, &format!("{}other.png", attempt_prefix())).await;
shutdown.cancel();
assert!(
ctx.wait_for_detached_mutations(DEADLINE).await,
"old instance mutations drain before reopen"
);
drop(store);
drop(ctx);
let (_ctx, store, shutdown) = open_store(root.path()).await;
metadata_sys::reload_bucket_metadata(store.clone(), bucket)
.await
.expect("reload dedicated bucket after restart");
assert_eq!(store.bucket_incarnation_id(bucket).await.expect("reopened incarnation"), incarnation);
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect("persisted seal remains idempotent");
shutdown.cancel();
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_put_seal_rejects_corrupt_and_wrong_incarnation_records() {
super::run_large_stack_async_test("artifact-put-corrupt", artifact_put_corrupt_record_case);
}
async fn artifact_put_corrupt_record_case() {
let root = tempfile::tempdir().expect("artifact invalid seal record directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-corrupt";
let prefix = attempt_prefix();
let old_incarnation = create_bucket(&store, bucket).await;
store
.clone()
.seal_artifact_attempt_puts(bucket, old_incarnation, &prefix)
.await
.expect("produce a real previous-generation seal");
let old_record = com::read_config(store.clone(), &artifact_put_seal_record_path(bucket, old_incarnation, &prefix))
.await
.expect("read actual seal bytes");
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete empty old-generation bucket");
let incarnation = create_bucket(&store, bucket).await;
assert_ne!(incarnation, old_incarnation);
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, old_incarnation, &prefix)
.await
.is_err(),
"wrong expected incarnation cannot seal current bucket"
);
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let before = disk_metadata(&store, bucket, &object).await;
let record = artifact_put_seal_record_path(bucket, incarnation, &prefix);
for invalid_record in [old_record, Vec::new(), b"not-json".to_vec()] {
com::save_config_with_opts(
store.clone(),
&record,
invalid_record,
&ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect("persist actual invalid seal record on all disks");
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.is_err(),
"invalid seal cannot be treated as absent or idempotent success"
);
}
shutdown.cancel();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_does_not_change_ordinary_bucket_puts() {
let root = tempfile::tempdir().expect("ordinary bucket seal regression directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-ordinary";
store
.make_bucket(
bucket,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create ordinary versioned bucket");
let incarnation = store.bucket_incarnation_id(bucket).await.expect("ordinary incarnation");
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let first = put(&store, bucket, &object).await;
assert!(
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.is_err(),
"ordinary bucket cannot acquire artifact seal semantics"
);
let second = put(&store, bucket, &object).await;
assert_ne!(first.version_id, second.version_id, "ordinary versioned PUT creates another version");
shutdown.cancel();
}
#[cfg(not(windows))]
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_put_seal_lock_survives_physical_publication_timeout() {
// Match large ECStore fixtures while retaining the current-thread clock required by pause/advance.
std::thread::Builder::new()
.name("artifact-put-physical-timeout".to_owned())
.stack_size(32 * 1024 * 1024)
.spawn(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("physical timeout test runtime should build");
runtime.block_on(artifact_put_physical_timeout_case());
})
.expect("physical timeout test thread should spawn")
.join()
.expect("physical timeout test thread should complete");
}
#[cfg(not(windows))]
async fn artifact_put_physical_timeout_case() {
use crate::disk::{Disk, ReadOptions, STORAGE_FORMAT_FILE, os};
temp_env::async_with_vars(
[
(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("60")),
(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("false")),
],
async {
let root = tempfile::tempdir().expect("artifact physical timeout directory");
let (_ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-put-physical-timeout";
let incarnation = create_bucket(&store, bucket).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let disks = store.pools[0].disk_set[0].disk_inventory().await;
let mut hooks = Vec::new();
let mut entered = Vec::new();
let mut releases = Vec::new();
let mut destinations = Vec::new();
for disk in disks.iter().flatten() {
let Disk::Local(local) = disk.as_ref() else {
panic!("physical timeout fixture requires local disks");
};
// Resolve through the mount lease; Linux may use an FD-based path.
let destination = local
.get_disk()
.get_object_path_for_io(bucket, &object)
.expect("real publication path");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
hooks.push(os::prepared_publication_test_hooks::install(
&destination.join(STORAGE_FORMAT_FILE),
move || {
let _ = entered_tx.send(());
// Dropping the sender also releases the syscall during panic unwinding.
let _ = release_rx.recv();
},
));
entered.push(entered_rx);
releases.push(release_tx);
destinations.push(destination);
}
assert_eq!(entered.len(), 4, "all four real disks must be online");
let version = Uuid::new_v4();
let put_store = store.clone();
let put_object = object.clone();
let mut replacement = tokio::spawn(async move {
put_store
.put_object(
bucket,
&put_object,
&mut PutObjReader::from_vec(vec![0xCC; 379]),
&ObjectOptions {
versioned: true,
version_id: Some(version.to_string()),
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
});
tokio::time::timeout(DEADLINE, async {
tokio::select! {
signals = futures::future::join_all(entered) => assert!(signals.into_iter().all(|signal| signal.is_ok())),
_ = &mut replacement => panic!("PUT cannot finish before its physical publishers are paused"),
}
})
.await
.expect("all physical xl.meta publications reach the blocking syscall");
// All disk wrapper timers exist, but their physical closures stay blocked.
tokio::time::pause();
tokio::time::advance(Duration::from_secs(61)).await;
tokio::time::resume();
let result = tokio::time::timeout(DEADLINE, replacement)
.await
.expect("PUT returns after disk wrapper timeouts")
.expect("PUT task joins");
assert!(result.is_err(), "four timed-out publishers cannot satisfy write quorum");
let record = artifact_put_seal_record_path(bucket, incarnation, &prefix);
let attempt_lock = store
.new_ns_lock(RUSTFS_META_BUCKET, &record)
.await
.expect("actual attempt coordination namespace");
let physical_owner_kept_lock = attempt_lock.get_write_lock(Duration::from_secs(1)).await.is_err();
// Drain physical syscalls before the regression assertion, including a RED run.
drop(releases);
for (disk, destination) in disks.iter().flatten().zip(&destinations) {
drop(
tokio::time::timeout(DEADLINE, os::acquire_rename_data_mutation_lease(&disk.path(), bucket, destination))
.await
.expect("released physical publisher drains"),
);
let published = disk
.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
.await
.expect("timed-out physical writer actually publishes the exact new version");
assert_eq!(published.size, 379);
}
assert!(
physical_owner_kept_lock,
"ARTIFACT_PUT_TAIL_BARRIER: disk workers retain the attempt lock after the PUT coordinator returns its timeout"
);
tokio::time::timeout(DEADLINE, store.clone().seal_artifact_attempt_puts(bucket, incarnation, &prefix))
.await
.expect("seal resumes after physical owners drain")
.expect("seal persists after timeout recovery");
let before = disk_metadata(&store, bucket, &object).await;
assert_rejected_without_new_version(&store, bucket, &object, &before).await;
shutdown.cancel();
},
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(storage_class_env)]
async fn artifact_put_seal_rejects_mismatched_topology_and_preserves_ordinary_local_puts() {
use crate::layout::endpoints::SetupType;
const TOPOLOGY_ERROR: &str = "artifact PUT sealing requires local storage or a distributed Unix URL topology";
let root = tempfile::tempdir().expect("artifact distributed topology directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
ctx.update_erasure_type(SetupType::Erasure).await;
let bucket = "artifact-put-distributed";
let incarnation = create_bucket(&store, bucket).await;
let ordinary = "artifact-put-distributed-ordinary";
store
.make_bucket(
ordinary,
&MakeBucketOptions {
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create ordinary versioned control bucket");
let ordinary_incarnation = store.bucket_incarnation_id(ordinary).await.expect("ordinary identity");
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
put(&store, bucket, &object).await;
let before = disk_metadata(&store, bucket, &object).await;
let first = put(&store, ordinary, &object).await;
// Keep every physical disk online: the gate must inspect configured topology.
ctx.update_erasure_type(SetupType::DistErasure).await;
assert!(ctx.is_dist_erasure().await);
let rejected = store
.put_object(
bucket,
&object,
&mut PutObjReader::from_vec(vec![0xDD; 379]),
&ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
},
)
.await
.expect_err("file endpoints with distributed lock mode must be rejected before publication");
assert!(rejected.to_string().contains(TOPOLOGY_ERROR), "ARTIFACT_PUT_TOPOLOGY: {rejected}");
assert_eq!(
disk_metadata(&store, bucket, &object).await,
before,
"ARTIFACT_PUT_TOPOLOGY: no disk gains a version"
);
let rejected = store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, &prefix)
.await
.expect_err("mismatched topology cannot claim a durable PUT seal");
assert!(rejected.to_string().contains(TOPOLOGY_ERROR), "ARTIFACT_PUT_TOPOLOGY: {rejected}");
// This local fixture has no distributed lock clients; ordinary PUT retains its local regression check.
ctx.update_erasure_type(SetupType::Erasure).await;
let second = put(&store, ordinary, &object).await;
assert_ne!(
first.version_id, second.version_id,
"ordinary local PUT stays available after artifact rejection"
);
assert_eq!(
store
.bucket_incarnation_id(ordinary)
.await
.expect("ordinary identity remains available"),
ordinary_incarnation
);
assert_eq!(
store
.bucket_incarnation_id(bucket)
.await
.expect("artifact identity remains available"),
incarnation
);
shutdown.cancel();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,689 +0,0 @@
#![cfg(unix)]
use super::{build_isolated_test_store, run_large_stack_async_test, without_storage_class_env};
use crate::{
bucket::{artifact::artifact_version_retirement_record_path, metadata_sys},
config::com,
disk::{DiskAPI as _, DiskStore, RUSTFS_META_BUCKET, ReadOptions, STORAGE_FORMAT_FILE, os},
error::Error,
object_api::{ObjectOptions, PutObjReader, ShardIntegrityWriteMode, WriteCompletion},
runtime::instance::InstanceContext,
storage_api_contracts::{
bucket::{BucketOperations as _, MakeBucketOptions},
heal::HealOperations,
object::ObjectIO as _,
},
store::ECStore,
};
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
use rustfs_utils::path::encode_dir_object;
use std::{
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio::io::AsyncReadExt as _;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
const DEADLINE: Duration = Duration::from_secs(30);
const PAYLOAD_SIZE: usize = 1024 * 1024;
fn attempt_prefix() -> String {
format!("outputs/74656e616e74/{}/{}/", Uuid::new_v4(), Uuid::new_v4())
}
fn put_options() -> ObjectOptions {
ObjectOptions {
versioned: true,
write_completion: WriteCompletion::TailDrained,
..Default::default()
}
}
fn heal_options() -> HealOpts {
HealOpts {
scan_mode: HealScanMode::Deep,
..Default::default()
}
}
async fn open_store(root: &std::path::Path) -> (Arc<InstanceContext>, Arc<ECStore>, CancellationToken) {
let (ctx, store, shutdown) = without_storage_class_env(build_isolated_test_store(root, "artifact-retirement", &[4])).await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
(ctx, store, shutdown)
}
async fn create_bucket(store: &ECStore, bucket: &str, artifact: bool) -> Uuid {
store
.make_bucket(
bucket,
&MakeBucketOptions {
artifact_base_prefix: artifact.then(|| "outputs/".to_owned()),
versioning_enabled: true,
..Default::default()
},
)
.await
.expect("create real four-disk versioned bucket");
store
.bucket_incarnation_id(bucket)
.await
.expect("read actual bucket incarnation")
}
async fn disks(store: &ECStore) -> Vec<DiskStore> {
let disks = store.pools[0].disk_set[0]
.disk_inventory()
.await
.into_iter()
.map(|disk| disk.expect("fixture disk remains online"))
.collect::<Vec<_>>();
assert_eq!(disks.len(), 4);
disks
}
fn object_path(disk: &DiskStore, bucket: &str, object: &str) -> PathBuf {
disk.get_object_path_for_io_if_local(bucket, &encode_dir_object(object))
.expect("fixture uses local disks")
.expect("resolve object through held mount")
}
async fn file_bytes(path: &Path) -> Option<Vec<u8>> {
match tokio::fs::read(path).await {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => panic!("read actual fixture file: {error}"),
}
}
async fn metadata(store: &ECStore, bucket: &str, object: &str) -> Vec<Option<Vec<u8>>> {
let mut result = Vec::new();
for disk in disks(store).await {
let path = object_path(&disk, bucket, object).join(STORAGE_FORMAT_FILE);
result.push(file_bytes(&path).await);
}
result
}
async fn put_payload(store: &ECStore, bucket: &str, object: &str, byte: u8, legacy: bool) -> Uuid {
let mut options = put_options();
if legacy {
// Exercise genuine supported minority metadata recovery, without fabricated proofs.
options.shard_integrity_write_mode = Some(ShardIntegrityWriteMode::Legacy);
}
let body = if object.ends_with('/') {
Vec::new()
} else {
vec![byte; PAYLOAD_SIZE]
};
store
.put_object(bucket, object, &mut PutObjReader::from_vec(body), &options)
.await
.expect("commit exact payload and version to all four disks")
.version_id
.expect("artifact PUT returns a non-null version")
}
async fn assert_payload(store: &ECStore, bucket: &str, object: &str, version: Uuid, byte: u8) {
let mut reader = store
.get_object_reader(
bucket,
object,
None,
Default::default(),
&ObjectOptions {
version_id: Some(version.to_string()),
..put_options()
},
)
.await
.expect("retirement leaves the exact committed version readable");
assert_eq!(reader.object_info.version_id, Some(version), "GET preserves exact version identity");
let mut body = Vec::new();
reader
.stream
.read_to_end(&mut body)
.await
.expect("read exact committed bytes");
let expected = if object.ends_with('/') {
Vec::new()
} else {
vec![byte; PAYLOAD_SIZE]
};
assert_eq!(body, expected, "retirement does not delete or replace committed bytes");
}
async fn remove_shard(store: &ECStore, bucket: &str, object: &str, version: Uuid) -> (DiskStore, PathBuf) {
let disk = disks(store).await.remove(0);
let info = disk
.read_version("", bucket, &encode_dir_object(object), &version.to_string(), &ReadOptions::default())
.await
.expect("read exact version before removing a real shard");
assert_eq!(info.version_id, Some(version));
let part = object_path(&disk, bucket, object)
.join(info.data_dir.expect("non-inline object has a data directory").to_string())
.join("part.1");
tokio::fs::remove_file(&part).await.expect("remove one real payload shard");
assert!(!part.exists());
(disk, part)
}
async fn assert_healthy_version(store: &ECStore, bucket: &str, object: &str, version: Uuid) {
for disk in disks(store).await {
let info = disk
.read_version("", bucket, &encode_dir_object(object), &version.to_string(), &ReadOptions::default())
.await
.expect("repaired disk retains exact committed metadata");
assert_eq!(info.version_id, Some(version));
assert_eq!(info.size, i64::try_from(PAYLOAD_SIZE).expect("payload size fits i64"));
let checked = disk
.check_parts(bucket, &encode_dir_object(object), &info)
.await
.expect("verify genuine repaired shard");
assert!(!checked.results.is_empty());
assert!(
checked
.results
.iter()
.all(|result| *result == crate::disk::CHECK_PART_SUCCESS),
"unretired version's real repaired shard passes integrity verification"
);
}
}
fn assert_policy_error(error: &Error) {
assert!(
!matches!(
error,
Error::FileNotFound | Error::FileVersionNotFound | Error::ObjectNotFound(_, _) | Error::VersionNotFound(_, _, _)
),
"retirement policy refusal must not claim physical absence: {error:?}"
);
}
async fn assert_repair_rejected(store: &ECStore, bucket: &str, object: &str, version: &str) -> Error {
let result = HealOperations::heal_object(store, bucket, object, version, &heal_options()).await;
let error = match result {
Err(error) | Ok((_, Some(error))) => error,
Ok((item, None)) => {
panic!("ARTIFACT_RETIREMENT_NO_RESURRECTION: retired exact version was accepted for real repair: {item:?}")
}
};
assert_policy_error(&error);
// The proof-returning API must preserve the same refusal without converting it
// into authoritative absence that a later destructive caller could consume.
match store.heal_object_with_proof(bucket, object, version, &heal_options()).await {
Err(error) => assert_policy_error(&error),
Ok(result) => {
assert!(result.absence.is_none(), "retirement never produces a physical absence proof");
assert_policy_error(result.error.as_ref().expect("retired repair returns a policy error"));
}
}
error
}
async fn assert_retired_repair_rejected(store: &ECStore, bucket: &str, object: &str, version: &str) {
let error = assert_repair_rejected(store, bucket, object, version).await;
assert!(
error.to_string().contains("artifact object version is retired"),
"ARTIFACT_RETIREMENT_NO_RESURRECTION: expected retirement refusal, received {error:?}"
);
}
async fn seal(store: &Arc<ECStore>, bucket: &str, incarnation: Uuid, prefix: &str) {
store
.clone()
.seal_artifact_attempt_puts(bucket, incarnation, prefix)
.await
.expect("persist the attempt PUT seal before retirement");
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_requires_seal_exact_identity_and_canonical_directory() {
run_large_stack_async_test("artifact-retirement-identity", || async {
let root = tempfile::tempdir().expect("retirement identity directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-identity";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let directory = format!("{prefix}folder/");
let version = put_payload(&store, bucket, &object, 0xA5, false).await;
let directory_version = put_payload(&store, bucket, &directory, 0, false).await;
let before = metadata(&store, bucket, &object).await;
assert!(
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.is_err(),
"unsealed attempt cannot retire a version"
);
seal(&store, bucket, incarnation, &prefix).await;
let wrong_prefix = attempt_prefix();
seal(&store, bucket, incarnation, &wrong_prefix).await;
for (expected, attempt, target, selected) in [
(Uuid::new_v4(), prefix.clone(), object.clone(), version),
(incarnation, wrong_prefix, object.clone(), version),
(incarnation, prefix.clone(), object.clone(), Uuid::nil()),
(incarnation, prefix.clone(), prefix.clone(), version),
] {
assert!(
store
.clone()
.retire_artifact_object_version(bucket, expected, &attempt, &target, selected)
.await
.is_err(),
"retirement requires the exact incarnation, attempt, inner object, and non-null version"
);
}
assert_eq!(metadata(&store, bucket, &object).await, before);
let ordinary = "artifact-retirement-ordinary";
let ordinary_incarnation = create_bucket(&store, ordinary, false).await;
assert!(
store
.clone()
.retire_artifact_object_version(ordinary, ordinary_incarnation, &prefix, &object, version)
.await
.is_err(),
"retirement is limited to dedicated artifact buckets"
);
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &directory, directory_version)
.await
.expect("retire logical directory identity");
let record = artifact_version_retirement_record_path(bucket, incarnation, &prefix, &directory, directory_version);
assert_eq!(
record,
artifact_version_retirement_record_path(
bucket,
incarnation,
&prefix,
&encode_dir_object(&directory),
directory_version
)
);
let bytes = com::read_config(store.clone(), &record)
.await
.expect("read durable directory retirement");
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &encode_dir_object(&directory), directory_version)
.await
.expect("encoded directory retirement retry is idempotent");
assert_eq!(com::read_config(store.clone(), &record).await.expect("read unchanged record"), bytes);
assert_retired_repair_rejected(&store, bucket, &directory, &directory_version.to_string()).await;
assert_retired_repair_rejected(&store, bucket, &encode_dir_object(&directory), &directory_version.to_string()).await;
assert_payload(&store, bucket, &directory, directory_version, 0).await;
// Rejected identity requests must not retire the valid regular object.
let (_, missing) = remove_shard(&store, bucket, &object, version).await;
let (_, error) = HealOperations::heal_object(store.as_ref(), bucket, &object, &version.to_string(), &heal_options())
.await
.expect("unretired version remains repairable after rejected retirement");
assert!(error.is_none(), "unretired repair failed: {error:?}");
assert!(missing.exists(), "unretired version's real shard is restored");
assert_healthy_version(&store, bucket, &object, version).await;
assert_payload(&store, bucket, &object, version, 0xA5).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_rejects_explicit_and_latest_repair_but_preserves_other_version_after_reopen() {
run_large_stack_async_test("artifact-retirement-versions", || async {
let root = tempfile::tempdir().expect("retired version fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-versions";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let retained = put_payload(&store, bucket, &object, 0xA5, false).await;
let retired = put_payload(&store, bucket, &object, 0xB6, false).await;
assert_ne!(retained, retired);
seal(&store, bucket, incarnation, &prefix).await;
let before = metadata(&store, bucket, &object).await;
let mut physical_parts = Vec::new();
for disk in disks(&store).await {
for version in [retained, retired] {
let info = disk
.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
.await
.expect("snapshot exact version before retirement");
assert_eq!(info.version_id, Some(version));
let part = object_path(&disk, bucket, &object)
.join(info.data_dir.expect("payload data directory").to_string())
.join("part.1");
physical_parts.push((part.clone(), tokio::fs::read(part).await.expect("snapshot original shard bytes")));
}
}
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, retired)
.await
.expect("persist exact latest-version retirement");
assert_eq!(
metadata(&store, bucket, &object).await,
before,
"retirement itself does not edit object metadata"
);
for (part, bytes) in physical_parts {
assert_eq!(
tokio::fs::read(part).await.expect("retirement leaves every physical shard"),
bytes,
"retirement changes neither version's physical data"
);
}
assert_payload(&store, bucket, &object, retired, 0xB6).await;
assert_payload(&store, bucket, &object, retained, 0xA5).await;
let (_, retired_part) = remove_shard(&store, bucket, &object, retired).await;
let before = metadata(&store, bucket, &object).await;
for selected in [retired.to_string(), String::new()] {
assert_retired_repair_rejected(&store, bucket, &object, &selected).await;
assert!(
!retired_part.exists(),
"ARTIFACT_RETIREMENT_NO_RESURRECTION: retired shard must stay absent"
);
assert_eq!(metadata(&store, bucket, &object).await, before, "retired repair publishes no metadata");
}
let (_, retained_part) = remove_shard(&store, bucket, &object, retained).await;
let (_, error) = HealOperations::heal_object(store.as_ref(), bucket, &object, &retained.to_string(), &heal_options())
.await
.expect("another version on the same object remains repairable");
assert!(error.is_none(), "other-version repair failed: {error:?}");
assert!(retained_part.exists(), "other version's actual shard is restored");
assert_healthy_version(&store, bucket, &object, retained).await;
assert!(!retired_part.exists(), "other-version repair must not restore the retired shard");
assert_payload(&store, bucket, &object, retained, 0xA5).await;
assert_payload(&store, bucket, &object, retired, 0xB6).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
drop(store);
drop(ctx);
let (ctx, store, shutdown) = open_store(root.path()).await;
assert_eq!(store.bucket_incarnation_id(bucket).await.expect("reopened incarnation"), incarnation);
assert_retired_repair_rejected(&store, bucket, &object, &retired.to_string()).await;
assert!(!retired_part.exists(), "reopen cannot forget exact-version retirement");
assert_payload(&store, bucket, &object, retired, 0xB6).await;
assert_payload(&store, bucket, &object, retained, 0xA5).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_blocks_minority_metadata_regeneration() {
run_large_stack_async_test("artifact-retirement-regeneration", || async {
let root = tempfile::tempdir().expect("minority metadata fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-regeneration";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let version = put_payload(&store, bucket, &object, 0xA5, true).await;
seal(&store, bucket, incarnation, &prefix).await;
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.expect("persist version retirement before minority metadata damage");
assert_payload(&store, bucket, &object, version, 0xA5).await;
let mut parts = Vec::new();
for (index, disk) in disks(&store).await.iter().enumerate() {
let info = disk
.read_version("", bucket, &object, &version.to_string(), &ReadOptions::default())
.await
.expect("read genuine legacy version before damage");
assert_eq!(info.version_id, Some(version));
let part = object_path(disk, bucket, &object)
.join(info.data_dir.expect("legacy payload has data directory").to_string())
.join("part.1");
parts.push((part.clone(), tokio::fs::read(&part).await.expect("snapshot real shard bytes")));
if index > 0 {
tokio::fs::remove_file(object_path(disk, bucket, &object).join(STORAGE_FORMAT_FILE))
.await
.expect("leave exactly one genuine metadata copy");
}
}
let before = metadata(&store, bucket, &object).await;
assert_eq!(before.iter().filter(|entry| entry.is_some()).count(), 1);
assert_retired_repair_rejected(&store, bucket, &object, &version.to_string()).await;
assert_eq!(
metadata(&store, bucket, &object).await,
before,
"ARTIFACT_RETIREMENT_NO_RESURRECTION: minority regeneration must not publish retired metadata"
);
for (path, bytes) in parts {
assert_eq!(tokio::fs::read(path).await.expect("retired shard remains physically present"), bytes);
}
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_corrupt_or_wrong_identity_records_fail_closed() {
run_large_stack_async_test("artifact-retirement-records", || async {
let root = tempfile::tempdir().expect("corrupt retirement fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-records";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let version = put_payload(&store, bucket, &object, 0xA5, false).await;
seal(&store, bucket, incarnation, &prefix).await;
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.expect("create genuine retirement record");
let record = artifact_version_retirement_record_path(bucket, incarnation, &prefix, &object, version);
let original = com::read_config(store.clone(), &record)
.await
.expect("read genuine retirement JSON");
let decoded: serde_json::Value = serde_json::from_slice(&original).expect("retirement record is JSON");
let mut invalid = vec![Vec::new(), b"not-json".to_vec()];
for (field, value) in [
("format_version", serde_json::json!(2)),
("bucket", serde_json::json!("different-bucket")),
("base_prefix", serde_json::json!("other/")),
("incarnation_id", serde_json::json!(Uuid::new_v4())),
("attempt_prefix", serde_json::json!(attempt_prefix())),
("object", serde_json::json!(format!("{prefix}other.png"))),
("version_id", serde_json::json!(Uuid::new_v4())),
("unexpected_field", serde_json::json!(true)),
] {
let mut changed = decoded.clone();
changed
.as_object_mut()
.expect("strict identity record is an object")
.insert(field.to_owned(), value);
invalid.push(serde_json::to_vec(&changed).expect("encode invalid persisted identity"));
}
let (_, missing) = remove_shard(&store, bucket, &object, version).await;
let before = metadata(&store, bucket, &object).await;
for bytes in invalid {
// Inject corrupt persisted bytes through real guarded config I/O; the
// fixture does not emulate the production retirement state machine.
com::with_config_object_write_lock(store.clone(), record.clone(), {
let store = store.clone();
let record = record.clone();
move |guard| async move { com::save_config_no_lock(store, &record, bytes, &guard).await }
})
.await
.expect("hold matching record guard")
.expect("persist invalid record on real disks");
assert!(
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &prefix, &object, version)
.await
.is_err(),
"corrupt or wrong-identity record cannot confirm idempotent retirement"
);
assert_repair_rejected(&store, bucket, &object, &version.to_string()).await;
assert!(!missing.exists(), "invalid record must not authorize real repair publication");
assert_eq!(metadata(&store, bucket, &object).await, before);
assert_payload(&store, bucket, &object, version, 0xA5).await;
}
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}
#[test]
#[serial_test::serial(storage_class_env)]
fn artifact_retirement_waits_for_cancelled_physical_repair_and_retries_cancelled_retirement() {
run_large_stack_async_test("artifact-retirement-physical-tail", || async {
let root = tempfile::tempdir().expect("physical retirement fixture directory");
let (ctx, store, shutdown) = open_store(root.path()).await;
let bucket = "artifact-retirement-physical-tail";
let incarnation = create_bucket(&store, bucket, true).await;
let prefix = attempt_prefix();
let object = format!("{prefix}image.png");
let version = put_payload(&store, bucket, &object, 0xA5, false).await;
seal(&store, bucket, incarnation, &prefix).await;
let (disk, part) = remove_shard(&store, bucket, &object, version).await;
let destination = object_path(&disk, bucket, &object);
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let repair_hook = os::prepared_publication_test_hooks::install_at(
os::prepared_publication_test_hooks::Stage::PreparedRename,
&destination.join(STORAGE_FORMAT_FILE),
move || {
let _ = entered_tx.send(());
// Sender drop also releases physical work when an assertion unwinds.
let _ = release_rx.recv();
},
);
let repair_store = store.clone();
let repair_object = object.clone();
let mut repair = tokio::spawn(async move {
HealOperations::heal_object(repair_store.as_ref(), bucket, &repair_object, &version.to_string(), &heal_options())
.await
});
tokio::time::timeout(DEADLINE, async {
tokio::select! {
entered = entered_rx => entered.expect("repair reaches real physical rename syscall"),
result = &mut repair => panic!("repair returned before syscall pause: {result:?}"),
}
})
.await
.expect("real repair worker is paused");
repair.abort();
assert!(repair.await.expect_err("cancel repair caller").is_cancelled());
let retire_store = store.clone();
let retire_prefix = prefix.clone();
let retire_object = object.clone();
let mut retirement = tokio::spawn(async move {
retire_store
.retire_artifact_object_version(bucket, incarnation, &retire_prefix, &retire_object, version)
.await
});
let blocked = tokio::time::timeout(Duration::from_millis(200), &mut retirement)
.await
.is_err();
drop(release_tx);
drop(
tokio::time::timeout(DEADLINE, os::acquire_rename_data_mutation_lease(&disk.path(), bucket, &destination))
.await
.expect("cancelled physical repair drains"),
);
drop(repair_hook);
if blocked {
tokio::time::timeout(DEADLINE, retirement)
.await
.expect("retirement resumes after real repair tail")
.expect("retirement task joins")
.expect("retirement persists");
}
assert!(
blocked,
"ARTIFACT_RETIREMENT_NO_RESURRECTION: retirement must wait for cancelled repair's physical publication tail"
);
assert_payload(&store, bucket, &object, version, 0xA5).await;
// A cancelled repair may roll back its destination; retirement must stop
// any later repair regardless of whether that first publication committed.
let before = metadata(&store, bucket, &object).await;
let part_before = file_bytes(&part).await;
assert_retired_repair_rejected(&store, bucket, &object, &version.to_string()).await;
assert_eq!(metadata(&store, bucket, &object).await, before);
assert_eq!(file_bytes(&part).await, part_before);
let second_prefix = attempt_prefix();
let second_object = format!("{second_prefix}cancelled.bin");
let second_version = put_payload(&store, bucket, &second_object, 0xB6, false).await;
seal(&store, bucket, incarnation, &second_prefix).await;
let record = artifact_version_retirement_record_path(bucket, incarnation, &second_prefix, &second_object, second_version);
let record_disk = disks(&store).await.remove(0);
let record_path = object_path(&record_disk, RUSTFS_META_BUCKET, &record).join(STORAGE_FORMAT_FILE);
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _retirement_hook = os::prepared_publication_test_hooks::install_at(
os::prepared_publication_test_hooks::Stage::PreparedRename,
&record_path,
move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
},
);
let retire_store = store.clone();
let retire_prefix = second_prefix.clone();
let retire_object = second_object.clone();
let mut retirement = tokio::spawn(async move {
retire_store
.retire_artifact_object_version(bucket, incarnation, &retire_prefix, &retire_object, second_version)
.await
});
tokio::time::timeout(DEADLINE, async {
tokio::select! {
entered = entered_rx => entered.expect("retirement reaches real record rename syscall"),
result = &mut retirement => panic!("retirement returned before durable record publication: {result:?}"),
}
})
.await
.expect("retirement's real config publication is paused");
retirement.abort();
assert!(retirement.await.expect_err("cancel retirement caller").is_cancelled());
let retry_store = store.clone();
let retry_prefix = second_prefix.clone();
let retry_object = second_object.clone();
let mut retry = tokio::spawn(async move {
retry_store
.retire_artifact_object_version(bucket, incarnation, &retry_prefix, &retry_object, second_version)
.await
});
let retry_blocked = tokio::time::timeout(Duration::from_millis(200), &mut retry).await.is_err();
drop(release_tx);
if retry_blocked {
tokio::time::timeout(DEADLINE, retry)
.await
.expect("retry waits for original publication tail")
.expect("retry task joins")
.expect("cancelled retirement is confirmed idempotently");
}
assert!(retry_blocked, "retirement retry cannot pass a physically incomplete original publication");
let persisted = com::read_config(store.clone(), &record)
.await
.expect("cancelled retirement left durable record");
store
.clone()
.retire_artifact_object_version(bucket, incarnation, &second_prefix, &second_object, second_version)
.await
.expect("idempotently confirm cancelled retirement");
assert_eq!(com::read_config(store.clone(), &record).await.expect("read confirmed record"), persisted);
assert_retired_repair_rejected(&store, bucket, &second_object, &second_version.to_string()).await;
assert_payload(&store, bucket, &second_object, second_version, 0xB6).await;
shutdown.cancel();
assert!(ctx.wait_for_detached_mutations(DEADLINE).await);
});
}

Some files were not shown because too many files have changed in this diff Show More