mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba1386a0fc | |||
| 6c42c94f17 | |||
| 17ce7097c4 | |||
| 1fbda624c9 | |||
| 41befd4978 | |||
| 0856c5a3cb | |||
| 3b4a35a4b6 | |||
| 057d05964b | |||
| d749a845d2 | |||
| 4a200357a6 | |||
| c398c45ae5 | |||
| 7c5ec6fb66 | |||
| f139c19f8a | |||
| d49309b117 | |||
| 2188ad2574 | |||
| 4f78887475 | |||
| 7b1ff0da45 | |||
| 94f344a7d4 | |||
| ff834ac98f | |||
| 3f0e592264 | |||
| c15af8e16d | |||
| cc05fd0e37 | |||
| 083e7e7350 | |||
| 6f1875e021 | |||
| 9a03007726 | |||
| 28cad76ce3 | |||
| d4d071869c | |||
| 88cc1a3f54 | |||
| d6e8973e59 | |||
| 6845775668 | |||
| 0d28dcaf37 | |||
| fbd2bd2ca1 | |||
| 39ac7321aa | |||
| e161e65147 | |||
| 9bcc86f49f | |||
| 564c4354c9 | |||
| e07caee90c | |||
| 4a41482a18 | |||
| 040720aa58 | |||
| 136296c76b | |||
| 5158b16917 | |||
| 3520821376 | |||
| 2924e37351 | |||
| cedaa66673 | |||
| 723e6fa58e | |||
| 058699c57a | |||
| 1c7d0d057e | |||
| 23c62f5dd7 | |||
| 95f21f0267 | |||
| 49eb74f209 | |||
| d954886cef | |||
| 1fd619992f | |||
| 7947978a45 | |||
| b2006b78e3 | |||
| 1123029eda | |||
| 529b440f67 | |||
| 4ed33f68a8 | |||
| 28fbe69909 |
@@ -1,2 +1,2 @@
|
||||
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
|
||||
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
|
||||
sha256-linux=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
sha256-darwin=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(¤t.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(¤t.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(())
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(®istry);
|
||||
}
|
||||
|
||||
#[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(®istry);
|
||||
}
|
||||
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(®istry);
|
||||
}
|
||||
|
||||
#[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(®istry);
|
||||
}
|
||||
|
||||
#[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(®istry);
|
||||
}
|
||||
|
||||
#[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(®istry);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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(×tamp_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,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();
|
||||
|
||||
@@ -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())]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+754
-1377
File diff suppressed because it is too large
Load Diff
+160
-60
@@ -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();
|
||||
|
||||
@@ -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(¤t);
|
||||
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(¤t);
|
||||
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");
|
||||
}
|
||||
}
|
||||
+4008
-10507
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(¤t).expect("initial disk root");
|
||||
let pin = root(¤t);
|
||||
drop(
|
||||
acquire(pin.try_clone().expect("root pin clone"), KEY)
|
||||
.await
|
||||
.expect("initialize pinned seal"),
|
||||
);
|
||||
std::fs::rename(¤t, &original).expect("replace configured pathname");
|
||||
std::fs::create_dir(¤t).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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+116
-596
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¶llel_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,
|
||||
¶llel_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
Reference in New Issue
Block a user