feat(concurrency): retain resource leases through native I/O (#8886)

feat(concurrency): retain resource leases across owned disk work
This commit is contained in:
Chris
2026-10-24 22:19:06 +09:00
committed by GitHub
parent 40837f67f8
commit 8aba025bd6
10 changed files with 1675 additions and 129 deletions
+832
View File
@@ -0,0 +1,832 @@
// Copyright 2026 RustFS Team
// Licensed under the Apache License, Version 2.0.
//! Mutable admission capacity without replacing or revoking held permits.
use std::num::NonZeroU16;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
use tokio::time::Instant;
/// 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
/// on first contention. It does not discover devices, start
/// workers, or decide resource policy. Cancellation drops the acquire future.
///
/// Uncontended admission is atomic. Parked work takes a FIFO admission turn,
/// so repeated fast acquisitions cannot starve an older waiter. Source/age
/// priority still belongs to the caller's scheduler, before joining this queue.
pub struct AdaptiveAdmission {
// One CAS observes both capacity and active count, so a concurrent shrink
// cannot admit a new unit using an already superseded capacity.
state: AtomicU64,
origin: Instant,
valid_until_millis: AtomicU64,
// Only the FIFO turn holder subscribes. A stored notification is harmless:
// capacity and freshness are always checked again after waking.
changed: Notify,
queued: AtomicUsize,
// FIFO admission turn, not a mutable-state lock. No storage lock or
// admitted permit may be held while joining. This private semaphore is
// never closed; release and resize do not acquire it.
turn: Semaphore,
maximum: u32,
slots: Box<[PermitSlots]>,
next_chunk: AtomicUsize,
}
struct PermitSlots {
free: AtomicU64,
references: [AtomicUsize; 64],
}
struct QueuedAdmission<'a>(&'a AdaptiveAdmission);
impl Drop for QueuedAdmission<'_> {
fn drop(&mut self) {
self.0.queued.fetch_sub(1, Ordering::AcqRel);
}
}
/// One active unit. Clones share that unit without allocating; the final drop
/// returns it to the same owner, including after resize or waiter cancellation.
#[must_use = "hold the permit for the admitted unit's complete lifetime"]
pub struct AdaptivePermit {
owner: Arc<AdaptiveAdmission>,
slot: usize,
}
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.
let owner = Arc::clone(&self.owner);
owner.slots[self.slot / 64].references[self.slot % 64].fetch_add(1, Ordering::Relaxed);
Self { owner, slot: self.slot }
}
}
impl std::fmt::Debug for AdaptivePermit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdaptivePermit")
.field("slot", &self.slot)
.field("owner", &self.owner.snapshot())
.finish()
}
}
/// One coherent view of configured capacity and actual active units.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdaptiveAdmissionSnapshot {
/// Current cap on new admissions. Zero pauses all new units.
pub limit: u32,
/// Existing units, which may temporarily exceed a reduced limit.
pub active: u32,
}
/// Confidence-qualified pressure for one complete sampling window.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourcePressure {
/// Missing, stale, reset, or insufficient observations; never evidence of idle.
Unknown,
/// A resource or foreground-protection threshold was reached.
Busy,
/// All required observations were fresh and below their low watermarks.
Stable,
}
/// Resource-owner input to the controller, after applying operator and kernel
/// caps. This describes one owner; callers must not duplicate a device's budget
/// for aliases, pools, or remote requesters.
#[derive(Debug, Clone, Copy)]
pub struct FeedbackObservation {
/// Identity of the resource/telemetry generation. Replacement resets probes.
pub generation: u64,
/// Upper bound imposed by resources and explicit configuration; zero blocks.
pub max_concurrency: u32,
/// Complete-window pressure, including the confidence of its sources.
pub pressure: ResourcePressure,
/// Cumulative completed work in this generation, not just scheduled work.
pub completed_units: Option<u64>,
}
/// Why the latest concurrency limit was selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedbackReason {
/// The resource cap is unavailable; no new unit can be bounded safely.
Unavailable,
/// A changed owner or reset work counter requires a new baseline.
NewBaseline,
/// The resource or operator cap reduced the permitted concurrency.
CapReduced,
/// Pressure caused an immediate multiplicative reduction.
Pressure,
/// Incomplete telemetry retains at most one maintenance unit.
LowConfidence,
/// Waiting for consecutive stable windows, work progress and cooldown.
Recovering,
/// One additional unit is being probed after a stable recovery period.
Probe,
}
/// One controller decision, suitable for metrics without dynamic labels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeedbackDecision {
/// New admissions use this cap; existing permits remain valid.
pub limit: u32,
/// Decision cause, distinct from a proof of work completion or storage health.
pub reason: FeedbackReason,
}
/// Single-owner additive probe/multiplicative decrease controller. It adjusts
/// only concurrency. Every source must be fresh before declaring `Stable`.
///
/// The sampler runs every second. A gap over two seconds resets recovery; eight
/// consecutive stable seconds and observed completed work allow one extra unit.
/// Pressure or lower caps apply immediately. This policy is not a performance
/// claim; its parameters require the same workload/foreground-SLO acceptance.
pub struct FeedbackController {
previous: Option<(Instant, FeedbackObservation)>,
stable_since: Option<Instant>,
last_change: Instant,
limit: u32,
}
impl Default for FeedbackController {
fn default() -> Self {
Self::new()
}
}
impl FeedbackController {
/// Start without a resource observation or an admission grant.
pub fn new() -> Self {
Self {
previous: None,
stable_since: None,
last_change: Instant::now(),
limit: 0,
}
}
/// 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 {
let observation = observation.map(|mut observation| {
observation.max_concurrency = observation.max_concurrency.min(gate.maximum);
observation
});
let decision = self.decide(Instant::now(), observation);
gate.set_sampled_limit(decision.limit, Duration::from_secs(3));
decision
}
fn decide(&mut self, now: Instant, observation: Option<FeedbackObservation>) -> FeedbackDecision {
let Some(observation) = observation else {
self.previous = None;
self.stable_since = None;
self.last_change = now;
self.limit = 0;
return FeedbackDecision {
limit: 0,
reason: FeedbackReason::Unavailable,
};
};
let previous = self.previous.replace((now, observation));
let contiguous = previous.is_some_and(|(at, old)| {
now.saturating_duration_since(at) <= Duration::from_secs(2)
&& old.generation == observation.generation
&& matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b >= a)
});
let advanced = previous
.is_some_and(|(_, old)| matches!((old.completed_units, observation.completed_units), (Some(a), Some(b)) if b > a));
let old_limit = self.limit;
let reason = if observation.max_concurrency == 0 {
self.limit = 0;
self.stable_since = None;
FeedbackReason::CapReduced
} else if observation.pressure == ResourcePressure::Busy {
self.limit = (self.limit / 2).max(1).min(observation.max_concurrency);
self.stable_since = None;
// Repeated busy windows restart cooldown, even at the minimum cap.
self.last_change = now;
FeedbackReason::Pressure
} else if observation.max_concurrency < self.limit {
self.limit = observation.max_concurrency;
self.stable_since = None;
FeedbackReason::CapReduced
} else if observation.pressure == ResourcePressure::Unknown || observation.completed_units.is_none() {
self.limit = 1.min(observation.max_concurrency);
self.stable_since = None;
FeedbackReason::LowConfidence
} else if !contiguous || self.limit == 0 {
self.limit = 1.min(observation.max_concurrency);
self.stable_since = None;
self.last_change = now;
FeedbackReason::NewBaseline
} else {
let since = *self.stable_since.get_or_insert(now);
if 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
{
self.limit += 1;
FeedbackReason::Probe
} else {
FeedbackReason::Recovering
}
};
if old_limit != self.limit {
self.last_change = now;
}
FeedbackDecision {
limit: self.limit,
reason,
}
}
}
impl AdaptiveAdmission {
/// 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.
pub fn new(maximum: NonZeroU16, limit: u32) -> Arc<Self> {
let maximum_u32 = u32::from(maximum.get());
let maximum = usize::from(maximum.get());
let slots = (0..maximum.div_ceil(64))
.map(|chunk| {
let count = (maximum - chunk * 64).min(64);
PermitSlots {
free: AtomicU64::new(if count == 64 { u64::MAX } else { (1_u64 << count) - 1 }),
references: std::array::from_fn(|_| AtomicUsize::new(0)),
}
})
.collect::<Vec<_>>()
.into_boxed_slice();
Arc::new(Self {
state: AtomicU64::new(u64::from(limit.min(maximum_u32)) << 32),
origin: Instant::now(),
valid_until_millis: AtomicU64::new(u64::MAX),
changed: Notify::new(),
queued: AtomicUsize::new(0),
turn: Semaphore::new(1),
maximum: maximum_u32,
slots,
next_chunk: AtomicUsize::new(0),
})
}
/// Publish a cap bounded by the immutable maximum, without losing held work
/// or changing owner identity.
pub fn set_limit(&self, limit: u32) {
let limit = limit.min(self.maximum);
let _ = self.state.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
Some((u64::from(limit) << 32) | (state & u64::from(u32::MAX)))
});
self.changed.notify_one();
}
/// Publish a cap with a finite observation lifetime. Expired observations
/// block new admissions even if the sampling worker has stopped. Existing
/// permits are retained. A single resource owner must serialize publishers.
///
/// Capacity is published before freshness, so a concurrent reader can
/// temporarily reject a fresh update but cannot use an increased lifetime
/// with the previous, potentially larger capacity.
pub fn set_sampled_limit(&self, limit: u32, valid_for: Duration) {
self.set_limit(limit);
let deadline = self
.elapsed_millis()
.saturating_add(u64::try_from(valid_for.as_millis()).unwrap_or(u64::MAX));
self.valid_until_millis.store(deadline, Ordering::Release);
self.changed.notify_one();
}
fn elapsed_millis(&self) -> u64 {
u64::try_from(self.origin.elapsed().as_millis()).unwrap_or(u64::MAX)
}
fn fresh(&self) -> bool {
self.elapsed_millis() < self.valid_until_millis.load(Ordering::Acquire)
}
/// Read the cap and active count from the same atomic observation.
pub fn snapshot(&self) -> AdaptiveAdmissionSnapshot {
// Acquire freshness first: a newly published lease must not be paired
// with capacity loaded before its preceding reduction.
let fresh = self.fresh();
let state = self.state.load(Ordering::Acquire);
AdaptiveAdmissionSnapshot {
limit: if fresh {
u32::try_from(state >> 32).unwrap_or(u32::MAX)
} else {
0
},
active: u32::try_from(state & u64::from(u32::MAX)).unwrap_or(u32::MAX),
}
}
/// Admit immediately if capacity remains, without a per-unit allocation.
/// The uncontended path uses atomics; refunding a racing acquisition may
/// wake the FIFO head through Tokio's notification queue.
pub fn try_acquire(self: &Arc<Self>) -> Option<AdaptivePermit> {
if self.queued.load(Ordering::Acquire) != 0 {
return None;
}
let permit = self.acquire_capacity()?;
// A waiter can register between the first queue check and the capacity
// CAS. Refund before exposing the permit rather than stealing its turn.
(self.queued.load(Ordering::Acquire) == 0).then_some(permit)
}
fn acquire_capacity(self: &Arc<Self>) -> Option<AdaptivePermit> {
if !self.fresh() {
return None;
}
let state = self.state.load(Ordering::Acquire);
if state & u64::from(u32::MAX) >= state >> 32 {
return None;
}
let slot = self.reserve_slot()?;
if self
.state
.try_update(Ordering::AcqRel, Ordering::Acquire, |state| {
let active = state & u64::from(u32::MAX);
(active < state >> 32).then(|| state + 1)
})
.is_err()
{
self.release_slot(slot);
self.changed.notify_one();
return None;
}
self.slots[slot / 64].references[slot % 64].store(1, Ordering::Release);
let permit = AdaptivePermit {
owner: self.clone(),
slot,
};
// The capacity CAS can retry across an observation expiry. Recheck
// before giving ownership to the caller; dropping refunds this unit.
self.fresh().then_some(permit)
}
fn reserve_slot(&self) -> Option<usize> {
let first = self.next_chunk.fetch_add(1, Ordering::Relaxed) % self.slots.len();
for offset in 0..self.slots.len() {
let chunk_index = (first + offset) % self.slots.len();
let free = &self.slots[chunk_index].free;
if let Ok(bits) = free.try_update(Ordering::AcqRel, Ordering::Acquire, |bits| (bits != 0).then(|| bits & (bits - 1)))
{
// trailing_zeros is in 0..64 because the zero word is rejected.
return Some(chunk_index * 64 + bits.trailing_zeros() as usize);
}
}
None
}
fn release_slot(&self, slot: usize) {
self.slots[slot / 64].free.fetch_or(1_u64 << (slot % 64), Ordering::Release);
}
/// 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.
pub async fn acquire(self: &Arc<Self>) -> AdaptivePermit {
if let Some(permit) = self.try_acquire() {
return permit;
}
self.queued.fetch_add(1, Ordering::AcqRel);
let _queued = QueuedAdmission(self);
// Retain the successful permit inside its Result. No code can close
// this private semaphore, so acquisition cannot return an error.
let _turn = self.turn.acquire().await;
loop {
let notified = self.changed.notified();
tokio::pin!(notified);
// Register before checking capacity to avoid a lost release/resize.
notified.as_mut().enable();
if let Some(permit) = self.acquire_capacity() {
return permit;
}
notified.await;
}
}
}
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();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn shared_unit_stays_admitted_until_native_worker_releases_its_last_clone() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let outer = gate.acquire().await;
let native = outer.clone();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let worker = tokio::task::spawn_blocking(move || {
started_tx.send(()).expect("worker started");
release_rx.blocking_recv().expect("release native worker");
drop(native);
});
started_rx.await.expect("native worker is live");
drop(outer);
assert_eq!(gate.snapshot().active, 1);
assert!(gate.try_acquire().is_none());
gate.set_limit(0);
assert_eq!(gate.snapshot().active, 1);
release_tx.send(()).expect("release tail");
worker.await.expect("native tail completed");
assert_eq!(gate.snapshot().active, 0);
gate.set_limit(1);
assert!(gate.try_acquire().is_some());
}
#[tokio::test]
async fn bounded_slots_cross_word_boundaries_and_recycle_only_after_final_drop() {
let maximum = NonZeroU16::new(65).expect("partial second chunk");
let gate = AdaptiveAdmission::new(maximum, u32::MAX);
assert_eq!(gate.snapshot().limit, 65);
for _ in 0..4 {
let units: Vec<_> = (0..65).map(|_| gate.try_acquire().expect("free slot")).collect();
let tails = units.clone();
assert_eq!(gate.snapshot().active, 65);
assert!(gate.try_acquire().is_none());
drop(units);
assert_eq!(gate.snapshot().active, 65);
gate.set_limit(0);
for (remaining, tail) in (0..65).rev().zip(tails) {
drop(tail);
assert_eq!(gate.snapshot().active, remaining);
}
gate.set_limit(u32::MAX);
assert_eq!(gate.snapshot().limit, 65);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn shared_units_survive_concurrent_clone_drop_reuse_and_resize() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded owner"), 8);
let mut workers = Vec::new();
for _ in 0..16 {
let gate = gate.clone();
workers.push(tokio::spawn(async move {
for _ in 0..64 {
let root = gate.acquire().await;
let one = root.clone();
let two = one.clone();
drop(root);
tokio::task::yield_now().await;
drop(two);
tokio::task::yield_now().await;
drop(one);
}
}));
}
for limit in [0, 1, 8, 2, 0, 8] {
gate.set_limit(limit);
tokio::task::yield_now().await;
}
tokio::time::timeout(Duration::from_secs(5), async {
for worker in workers {
worker.await.expect("lease worker");
}
})
.await
.expect("all lease workers complete");
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
let full: Vec<_> = (0..8).map(|_| gate.try_acquire().expect("all slots returned")).collect();
assert!(gate.try_acquire().is_none());
drop(full);
}
#[tokio::test]
async fn queued_work_cannot_be_starved_by_new_fast_acquisitions() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 1);
let held = gate.acquire().await;
let mut first = Box::pin(gate.acquire());
let mut second = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll(&mut context).is_pending());
assert!(second.as_mut().poll(&mut context).is_pending());
drop(held);
assert!(gate.try_acquire().is_none(), "new work must not steal a parked worker's turn");
assert!(second.as_mut().poll(&mut context).is_pending(), "later work cannot pass the queue head");
let Poll::Ready(first_permit) = first.as_mut().poll(&mut context) else {
panic!("queue head must run")
};
drop(first_permit);
let Poll::Ready(second_permit) = second.as_mut().poll(&mut context) else {
panic!("second waiter must run")
};
drop(second_permit);
assert!(gate.try_acquire().is_some(), "drained queue restores the atomic fast path");
}
#[tokio::test]
async fn cancelling_queued_work_preserves_fifo_capacity_and_fast_path() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut head = Box::pin(gate.acquire());
let mut middle = Box::pin(gate.acquire());
let mut tail = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(head.as_mut().poll(&mut context).is_pending());
assert!(middle.as_mut().poll(&mut context).is_pending());
assert!(tail.as_mut().poll(&mut context).is_pending());
drop(middle);
drop(head);
gate.set_limit(1);
assert!(
gate.try_acquire().is_none(),
"cancelled head must not expose the surviving waiter's capacity"
);
let Poll::Ready(permit) = tail.as_mut().poll(&mut context) else {
panic!("surviving waiter must run")
};
drop(permit);
assert!(gate.try_acquire().is_some());
gate.set_limit(0);
let mut cancelled = Box::pin(gate.acquire());
assert!(cancelled.as_mut().poll(&mut context).is_pending());
drop(cancelled);
gate.set_limit(1);
assert!(
gate.try_acquire().is_some(),
"cancelling the final waiter must not strand an admission turn"
);
}
#[tokio::test]
async fn cancelling_a_notified_head_hands_capacity_to_the_next_waiter() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
let mut head = Box::pin(gate.acquire());
let mut next = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(head.as_mut().poll(&mut context).is_pending());
assert!(next.as_mut().poll(&mut context).is_pending());
gate.set_limit(1);
// Cancel after notification, before the head consumes its capacity.
drop(head);
let Poll::Ready(permit) = next.as_mut().poll(&mut context) else {
panic!("successor must observe capacity without another notification")
};
assert_eq!(gate.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
drop(permit);
}
#[tokio::test(start_paused = true)]
async fn one_resize_serves_multiple_fifo_turns_but_stored_wakes_cannot_bypass_expiry() {
use std::task::{Context, Poll, Waker};
let gate = AdaptiveAdmission::new(NonZeroU16::new(2).expect("two units"), 0);
let mut first = Box::pin(gate.acquire());
let mut second = Box::pin(gate.acquire());
let mut context = Context::from_waker(Waker::noop());
assert!(first.as_mut().poll(&mut context).is_pending());
assert!(second.as_mut().poll(&mut context).is_pending());
gate.set_sampled_limit(2, Duration::from_secs(1));
let Poll::Ready(one) = first.as_mut().poll(&mut context) else {
panic!("first turn must observe resize")
};
let Poll::Ready(two) = second.as_mut().poll(&mut context) else {
panic!("second turn must observe resize without releasing the first unit")
};
tokio::time::advance(Duration::from_secs(1)).await;
drop(one);
drop(two);
let mut expired = Box::pin(gate.acquire());
assert!(expired.as_mut().poll(&mut context).is_pending(), "stored wake is not a fresh lease");
gate.set_sampled_limit(1, Duration::from_secs(1));
let Poll::Ready(renewed) = expired.as_mut().poll(&mut context) else {
panic!("fresh observation must wake expired admission")
};
drop(renewed);
assert_eq!(gate.snapshot().active, 0);
}
use std::future::Future;
use std::task::Poll;
#[tokio::test]
async fn shrink_preserves_all_held_units_and_blocks_until_below_new_limit() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 3);
let first = owner.acquire().await;
let second = owner.acquire().await;
let third = owner.acquire().await;
owner.set_limit(1);
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 3 });
drop(first);
assert!(owner.try_acquire().is_none());
drop(second);
assert!(owner.try_acquire().is_none());
drop(third);
let fourth = owner.try_acquire().expect("last old permit returned to original owner");
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 1, active: 1 });
drop(fourth);
assert_eq!(owner.snapshot().active, 0);
}
#[tokio::test]
async fn zero_capacity_and_cancelled_waiter_do_not_leak_or_bypass() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut waiting = Box::pin(owner.acquire());
std::future::poll_fn(|cx| {
assert!(waiting.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
drop(waiting);
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 0 });
assert!(owner.try_acquire().is_none());
owner.set_limit(1);
let permit = owner.acquire().await;
assert!(owner.try_acquire().is_none());
drop(permit);
assert_eq!(owner.snapshot().active, 0);
}
#[tokio::test]
async fn resize_wakes_a_registered_waiter_without_an_unrelated_release() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut waiting = Box::pin(owner.acquire());
std::future::poll_fn(|cx| {
assert!(waiting.as_mut().poll(cx).is_pending());
Poll::Ready(())
})
.await;
owner.set_limit(1);
let permit = tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
.await
.expect("resize notification");
assert_eq!(owner.snapshot().active, 1);
drop(permit);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_resize_release_and_acquire_keep_the_owner_count_exact() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 8);
let mut tasks = Vec::new();
for _ in 0..16 {
let owner = owner.clone();
tasks.push(tokio::spawn(async move {
for _ in 0..100 {
let permit = owner.acquire().await;
tokio::task::yield_now().await;
drop(permit);
}
}));
}
for limit in [1, 0, 4, 2, 8, 0, u32::MAX, 1] {
owner.set_limit(limit);
tokio::task::yield_now().await;
}
owner.set_limit(8);
tokio::time::timeout(std::time::Duration::from_secs(5), async {
for task in tasks {
task.await.expect("admission task");
}
})
.await
.expect("all waiters make progress");
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 8, active: 0 });
}
#[tokio::test(start_paused = true)]
async fn expired_observation_blocks_new_work_without_revoking_held_permits() {
let owner = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
owner.set_sampled_limit(2, Duration::from_secs(2));
let held = owner.acquire().await;
tokio::time::advance(Duration::from_secs(2)).await;
assert!(owner.try_acquire().is_none());
assert_eq!(owner.snapshot(), AdaptiveAdmissionSnapshot { limit: 0, active: 1 });
drop(held);
assert_eq!(owner.snapshot().active, 0);
owner.set_sampled_limit(1, Duration::from_secs(2));
let fresh = owner.try_acquire().expect("fresh observation reopens same owner");
owner.set_sampled_limit(8, Duration::ZERO);
assert!(owner.try_acquire().is_none());
drop(fresh);
assert_eq!(owner.snapshot().active, 0);
}
fn healthy(completed: u64) -> FeedbackObservation {
FeedbackObservation {
generation: 1,
max_concurrency: 8,
pressure: ResourcePressure::Stable,
completed_units: Some(completed),
}
}
#[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);
let mut controller = FeedbackController::new();
assert_eq!(controller.update(&gate, Some(healthy(0))).limit, 1);
for completed in 1..=25 {
tokio::time::advance(Duration::from_secs(1)).await;
let limit = controller.update(&gate, Some(healthy(completed))).limit;
assert_eq!(
limit,
if completed < 9 {
1
} else if completed < 17 {
2
} else if completed < 25 {
3
} else {
4
}
);
}
let held = gate.try_acquire().expect("existing work");
let mut pressure = healthy(26);
pressure.pressure = ResourcePressure::Busy;
assert_eq!(
controller.update(&gate, Some(pressure)),
FeedbackDecision {
limit: 2,
reason: FeedbackReason::Pressure
}
);
pressure.max_concurrency = 0;
assert_eq!(controller.update(&gate, Some(pressure)).limit, 0);
assert_eq!(gate.snapshot().active, 1);
assert!(gate.try_acquire().is_none());
drop(held);
}
#[tokio::test(start_paused = true)]
async fn controller_missing_low_sample_stalled_or_changed_sources_never_expand() {
for kind in 0..5 {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
for tick in 0..32 {
let mut observation = healthy(tick);
match kind {
0 => observation.pressure = ResourcePressure::Unknown,
1 => observation.completed_units = None,
2 => observation.completed_units = Some(0),
3 => observation.generation = tick,
_ => {}
}
tokio::time::advance(Duration::from_secs(if kind == 4 { 3 } else { 1 })).await;
assert!(controller.update(&gate, Some(observation)).limit <= 1, "kind {kind}");
}
assert_eq!(
controller.update(&gate, None),
FeedbackDecision {
limit: 0,
reason: FeedbackReason::Unavailable
}
);
assert!(gate.try_acquire().is_none());
}
}
#[tokio::test(start_paused = true)]
async fn controller_resumes_after_zero_cap_without_requiring_impossible_progress() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
let mut sample = healthy(0);
sample.max_concurrency = 0;
assert_eq!(controller.update(&gate, Some(sample)).limit, 0);
tokio::time::advance(Duration::from_secs(1)).await;
sample.max_concurrency = 8;
assert_eq!(controller.update(&gate, Some(sample)).limit, 1);
assert!(gate.try_acquire().is_some(), "minimum progress restarts inside the restored cap");
}
#[tokio::test(start_paused = true)]
async fn controller_applies_pressure_reduction_and_lower_cap_in_the_same_window() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(8).expect("bounded test owner"), 0);
let mut controller = FeedbackController::new();
for completed in 0..=25 {
controller.update(&gate, Some(healthy(completed)));
tokio::time::advance(Duration::from_secs(1)).await;
}
assert_eq!(gate.snapshot().limit, 4);
let mut sample = healthy(26);
sample.max_concurrency = 3;
sample.pressure = ResourcePressure::Busy;
assert_eq!(controller.update(&gate, Some(sample)).limit, 2);
}
}
+1
View File
@@ -36,6 +36,7 @@
// Re-exported io-core type used by downstream timeout implementations.
pub use rustfs_io_core::OperationProgress;
pub mod adaptive;
mod backpressure;
mod deadlock;
mod queue;
+62 -4
View File
@@ -16,6 +16,7 @@
//! PUT admission precedes staging; receiver and GC disk work share a separate
//! budget so coordinators waiting for peers cannot exhaust receiver capacity.
use crate::disk::io_admission::{current_resource_unit, with_optional_resource_unit};
use crate::disk::{LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_DISK};
use std::collections::BTreeSet;
use std::future::Future;
@@ -422,7 +423,7 @@ impl CleanupRuntime {
// the result receiver's lifetime to discard work cancelled during
// runtime scheduling, before its first poll can start filesystem I/O.
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let task = runtime.spawn(async move {
let task = runtime.spawn(with_optional_resource_unit(current_resource_unit(), async move {
if result_tx.is_closed() {
return;
}
@@ -430,7 +431,7 @@ impl CleanupRuntime {
// through I/O even if the waiter cancels and closes result_rx.
let result = execute_disk(pending, Some(active), execution, started, future).await;
let _ = result_tx.send(result);
});
}));
match result_rx.await {
Ok(result) => result,
Err(err) => {
@@ -469,11 +470,11 @@ impl CleanupRuntime {
gates: Some((self.disk_admission.clone(), self.disk_execution.clone())),
};
handle
.spawn(async move {
.spawn(with_optional_resource_unit(current_resource_unit(), async move {
let active = execution.acquire_owned().await.map_err(super::error::DiskError::other)?;
let _active = Admission::new(active, "rustfs_cleanup_gc_active");
scan(budget).await
})
}))
.await
.map_err(super::error::DiskError::other)?
}
@@ -582,6 +583,7 @@ where
F::Output: Send + 'static,
{
let started = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
let future = with_optional_resource_unit(current_resource_unit(), future);
match reservation {
Some(reservation) => reservation.handle.clone().spawn(async move {
// This semaphore is never closed. Both queued work and overflow
@@ -620,6 +622,62 @@ mod tests {
use std::time::Duration;
use tokio::sync::oneshot;
#[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 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;
}
_ => 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 = 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");
}
}
fn config(pending: usize, workers: usize, blocking_threads: usize) -> Config {
Config {
pending,
+29 -11
View File
@@ -15,7 +15,7 @@
use crate::disk::cleanup_runtime;
use crate::disk::io_admission::{
AdmissionReader, BoxedChunkReader, DiskIoAdmission, DiskIoClass, DiskIoContext, current_disk_io_context,
with_disk_io_context, with_disk_io_permit,
current_resource_unit, with_disk_io_context, with_disk_io_permit, with_optional_resource_unit,
};
use crate::disk::{
CheckPartsResp, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskError, DiskInfo, DiskInfoOptions, DiskLocation, Endpoint,
@@ -449,10 +449,10 @@ where
if external_guard.is_none() {
return operation().await;
}
tokio::spawn(async move {
tokio::spawn(with_optional_resource_unit(current_resource_unit(), async move {
let _external_guard = external_guard;
operation().await
})
}))
.await
.map_err(|_| Error::other("owned mutation task failed"))?
}
@@ -1648,6 +1648,7 @@ impl LocalDiskWrapper {
cancellation: CancellationToken::new(),
admission: None,
permit: None,
resource_unit: current_resource_unit(),
})
}
@@ -3785,32 +3786,49 @@ mod tests {
#[tokio::test]
async fn owned_mutation_keeps_publication_guard_after_waiter_cancellation() {
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one resource unit"), 1);
let resource_unit = resources.acquire().await;
let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let guard: Arc<dyn Send + Sync> = Arc::new(DropProbe(Arc::clone(&drops)));
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
let waiter = tokio::spawn(run_owned_mutation(Some(guard), move || async move {
started_tx.send(()).expect("mutation should signal start");
release_rx.await.expect("mutation should be released");
finished_tx.send(()).expect("mutation should signal completion");
Ok::<_, Error>(())
}));
let waiter = tokio::spawn(crate::disk::with_background_resource_unit(
resource_unit,
run_owned_mutation(Some(guard), move || async move {
let inherited = current_resource_unit().is_some();
assert!(current_disk_io_context().is_none(), "preserve the owned task's original disk policy");
started_tx.send(inherited).expect("mutation should signal start");
release_rx.await.expect("mutation should be released");
finished_tx.send(()).expect("mutation should signal completion");
Ok::<_, Error>(())
}),
));
started_rx.await.expect("mutation owner should start");
let inherited = started_rx.await.expect("mutation owner should start");
waiter.abort();
assert!(waiter.await.expect_err("caller was cancelled").is_cancelled());
let held_after_cancel = resources.snapshot().active;
assert_eq!(drops.load(std::sync::atomic::Ordering::SeqCst), 0);
release_tx.send(()).expect("mutation owner should still be alive");
finished_rx.await.expect("mutation owner should finish");
tokio::time::timeout(Duration::from_secs(1), async {
while drops.load(std::sync::atomic::Ordering::SeqCst) == 0 {
while drops.load(std::sync::atomic::Ordering::SeqCst) == 0 || resources.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("publication guard should be released after mutation completion");
assert!(inherited, "owned mutation must retain its resource unit");
assert_eq!(
held_after_cancel, 1,
"cancelling the waiter must not release a live mutation's resource unit"
);
}
struct PendingWriter;
+243 -7
View File
@@ -24,6 +24,7 @@ use std::task::{Context, Poll};
use std::time::Duration;
use metrics::{counter, gauge, histogram};
use rustfs_concurrency::adaptive::AdaptivePermit;
use rustfs_rio::ChunkReader;
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::Notify;
@@ -35,6 +36,7 @@ const BACKGROUND_MAX_WAIT: Duration = Duration::from_millis(100);
tokio::task_local! {
static DISK_IO_CONTEXT: DiskIoContext;
static RESOURCE_IO_UNIT: AdaptivePermit;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -49,6 +51,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>,
}
/// Mark disk I/O issued by `future` as background work for local-disk admission.
@@ -60,12 +63,43 @@ pub async fn with_background_disk_io<F: Future>(cancellation: CancellationToken,
cancellation,
admission: None,
permit: None,
resource_unit: current_resource_unit(),
},
future,
)
.await
}
/// Retain an already admitted resource unit without changing disk classification,
/// cancellation or admission. Acquire it before namespace, cache or commit locks.
/// Existing background scopes and disk leases propagate it into native I/O.
/// Use this for physical I/O ownership, not node-wide scheduling capacity that
/// must return with a cancelled waiter so healthy disks can progress independently
/// 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
}
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()
})
}
/// 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 {
match unit {
Some(unit) => with_background_resource_unit(unit, future).await,
None => future.await,
}
}
pub(crate) async fn with_disk_io_context<F: Future>(context: DiskIoContext, future: F) -> F::Output {
DISK_IO_CONTEXT.scope(context, future).await
}
@@ -79,7 +113,11 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
cancellation: CancellationToken::new(),
admission: None,
permit: None,
resource_unit: None,
});
if context.resource_unit.is_none() {
context.resource_unit = permit.lease.resource_unit.clone();
}
context.admission = Some(permit.admission());
with_disk_io_context(
DiskIoContext {
@@ -91,15 +129,32 @@ pub(crate) async fn with_disk_io_permit<F: Future>(permit: Option<DiskIoPermit>,
.await
}
pub(crate) async fn with_optional_disk_io_context<F: Future>(context: Option<DiskIoContext>, future: F) -> F::Output {
match context {
Some(context) => with_disk_io_context(context, future).await,
None => future.await,
pub(crate) fn with_optional_disk_io_context<F: Future>(
context: Option<DiskIoContext>,
future: F,
) -> impl Future<Output = F::Output> {
// Capture at future construction, before JoinSet/spawn moves polling to a
// different task. A resource-only scope has no DiskIoContext to carry it.
let unit = current_resource_unit();
async move {
let future = with_optional_resource_unit(unit, future);
match context {
Some(context) => with_disk_io_context(context, future).await,
None => future.await,
}
}
}
pub(crate) fn current_disk_io_context() -> Option<DiskIoContext> {
DISK_IO_CONTEXT.try_with(Clone::clone).ok()
DISK_IO_CONTEXT
.try_with(|context| {
let mut context = context.clone();
if let Ok(unit) = RESOURCE_IO_UNIT.try_with(Clone::clone) {
context.resource_unit = Some(unit);
}
context
})
.ok()
}
/// Acquire one low-level permit only when the current walker explicitly owns a gate.
@@ -175,6 +230,10 @@ impl DiskIoAdmission {
}
pub(crate) fn foreground(self: &Arc<Self>) -> DiskIoPermit {
self.foreground_with_resource(current_resource_unit())
}
fn foreground_with_resource(self: &Arc<Self>, resource_unit: Option<AdaptivePermit>) -> DiskIoPermit {
self.foreground_active.fetch_add(1, Ordering::AcqRel);
if self.background_waiters.load(Ordering::Acquire) > 0 {
self.record_state();
@@ -183,11 +242,20 @@ impl DiskIoAdmission {
lease: Arc::new(DiskIoPermitLease {
admission: self.clone(),
class: DiskIoClass::Foreground,
resource_unit,
}),
}
}
pub(crate) async fn background(self: &Arc<Self>, cancellation: &CancellationToken) -> std::io::Result<DiskIoPermit> {
self.background_with_resource(cancellation, current_resource_unit()).await
}
async fn background_with_resource(
self: &Arc<Self>,
cancellation: &CancellationToken,
resource_unit: Option<AdaptivePermit>,
) -> std::io::Result<DiskIoPermit> {
let started_at = Instant::now();
let ticket = {
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
@@ -248,6 +316,7 @@ impl DiskIoAdmission {
lease: Arc::new(DiskIoPermitLease {
admission: self.clone(),
class: DiskIoClass::Background,
resource_unit,
}),
});
}
@@ -360,6 +429,7 @@ impl DiskIoPermit {
struct DiskIoPermitLease {
admission: Arc<DiskIoAdmission>,
class: DiskIoClass,
resource_unit: Option<AdaptivePermit>,
}
pub(crate) fn spawn_blocking_with_disk_io_permit<T, F>(
@@ -370,8 +440,12 @@ where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
// A supplied disk permit can predate this resource scope or carry another
// owner's unit. Preserve the current unit independently until native I/O ends.
let resource_unit = current_resource_unit();
tokio::task::spawn_blocking(move || {
let _io_permit = io_permit;
let _resource_unit = resource_unit;
operation()
})
}
@@ -436,7 +510,7 @@ impl<R> AdmissionReader<R> {
}
if self.context.class == DiskIoClass::Foreground {
self.permit = Some(self.admission.foreground());
self.permit = Some(self.admission.foreground_with_resource(self.context.resource_unit.clone()));
return Poll::Ready(Ok(()));
}
@@ -444,7 +518,10 @@ impl<R> AdmissionReader<R> {
if wait.is_none() {
let admission = self.admission.clone();
let cancellation = self.context.cancellation.clone();
*wait = Some(Box::pin(async move { admission.background(&cancellation).await }));
let resource_unit = self.context.resource_unit.clone();
*wait = Some(Box::pin(
async move { admission.background_with_resource(&cancellation, resource_unit).await },
));
}
let Some(wait_future) = wait.as_mut() else {
return Poll::Ready(Err(io::Error::other("background disk I/O admission waiter is missing")));
@@ -514,8 +591,166 @@ impl ChunkReader for BoxedChunkReader {
#[cfg(test)]
mod tests {
use super::*;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn resource_unit_outlives_cancelled_scope_until_native_io_finishes() {
for permit_mode in 0..4 {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let previous_gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("previous owner"), 1);
let unit = gate.acquire().await;
let disk = DiskIoAdmission::new("resource-unit-native");
let existing_permit = match permit_mode {
2 => Some(disk.foreground()),
3 => Some(with_background_resource_unit(previous_gate.acquire().await, async { disk.foreground() }).await),
_ => None,
};
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let task = tokio::spawn(with_background_resource_unit(unit, async move {
with_background_disk_io(CancellationToken::new(), async {
let permit = if existing_permit.is_some() {
existing_permit
} else if permit_mode == 1 {
Some(disk.background(&CancellationToken::new()).await.expect("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("native I/O release");
})
.await
.expect("native I/O completed");
})
.await;
}));
started_rx.await.expect("native worker owns unit");
task.abort();
assert!(task.await.expect_err("scope was cancelled").is_cancelled());
let held = gate.snapshot().active;
let previous_held = previous_gate.snapshot().active;
let rejected = gate.try_acquire().is_none();
gate.set_limit(0);
release_tx.send(()).expect("release native I/O");
tokio::time::timeout(Duration::from_secs(5), async {
while gate.snapshot().active != 0 || previous_gate.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("native completion releases the unit");
gate.set_limit(1);
assert!(gate.try_acquire().is_some());
assert_eq!(held, 1, "mode {permit_mode}: native I/O still owns the current unit");
assert!(rejected, "mode {permit_mode}: a live native worker must retain admission");
assert_eq!(previous_held, u32::from(permit_mode == 3), "preserve any older disk lease's unit");
}
}
#[tokio::test]
async fn escaped_disk_permit_restores_resource_context_without_a_new_unit() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let disk = DiskIoAdmission::new("resource-unit-escape");
let permit = with_background_resource_unit(gate.acquire().await, async {
disk.background(&CancellationToken::new()).await.expect("disk permit")
})
.await;
assert_eq!(gate.snapshot().active, 1);
with_background_disk_io(CancellationToken::new(), async {
with_disk_io_permit(Some(permit), async {
let unit = current_resource_unit().expect("escaped disk lease restores unit into an existing context");
assert_eq!(gate.snapshot().active, 1, "cloning retains one unit rather than charging twice");
drop(unit);
})
.await;
})
.await;
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn escaped_stream_retains_resource_unit_outside_task_local_scope() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let disk = DiskIoAdmission::new("resource-unit-stream");
let (mut writer, inner) = tokio::io::duplex(8);
let mut reader = with_background_resource_unit(gate.acquire().await, async {
with_background_disk_io(CancellationToken::new(), async {
AdmissionReader::new(inner, disk, current_disk_io_context().expect("resource context"))
})
.await
})
.await;
assert_eq!(gate.snapshot().active, 1);
writer.write_all(b"rust").await.expect("write complete stream");
drop(writer);
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await.expect("read outside original scope");
assert_eq!(bytes, b"rust");
assert_eq!(gate.snapshot().active, 1, "reader still owns its context");
drop(reader);
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn resource_scope_preserves_existing_disk_policy_and_cancellation() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
with_background_resource_unit(gate.acquire().await, async {
assert!(current_disk_io_context().is_none(), "resource ownership does not classify disk I/O");
assert!(current_resource_unit().is_some());
})
.await;
let cancellation = CancellationToken::new();
let existing = DiskIoContext {
class: DiskIoClass::Foreground,
cancellation: cancellation.clone(),
admission: None,
permit: None,
resource_unit: None,
};
with_disk_io_context(existing, async {
with_background_resource_unit(gate.acquire().await, async {
cancellation.cancel();
let context = current_disk_io_context().expect("existing disk context");
assert_eq!(context.class, DiskIoClass::Foreground);
assert!(
context.resource_unit.is_some(),
"captured disk context must retain the current resource unit"
);
assert!(context.cancellation.is_cancelled(), "retain the original cancellation token");
assert!(current_resource_unit().is_some());
})
.await;
})
.await;
assert_eq!(gate.snapshot().active, 0);
}
#[tokio::test]
async fn resource_only_scope_survives_existing_optional_context_spawn_boundary() {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let mut worker = None;
with_background_resource_unit(gate.acquire().await, async {
worker = Some(tokio::spawn(with_optional_disk_io_context(current_disk_io_context(), async {
release_rx.await.expect("release child after caller scope ends");
assert!(current_disk_io_context().is_none());
assert!(current_resource_unit().is_some());
})));
})
.await;
let held = gate.snapshot().active;
release_tx.send(()).expect("child remains alive");
worker
.expect("scoped child was spawned")
.await
.expect("child preserves resource-only scope");
assert_eq!(held, 1);
assert_eq!(gate.snapshot().active, 0);
}
struct BlockingWorker(tokio::task::JoinHandle<()>);
async fn wait_for_background_waiters(admission: &DiskIoAdmission, expected: usize) {
@@ -636,6 +871,7 @@ mod tests {
cancellation: CancellationToken::new(),
admission: None,
permit: None,
resource_unit: None,
},
);
let read = tokio::spawn(async move {
+53 -2
View File
@@ -50,7 +50,7 @@ use crate::diagnostics::get::{
#[cfg(test)]
use crate::disk::HEALING_MARKER_PATH;
use crate::disk::disk_store::{get_drive_walkdir_stall_timeout, get_object_disk_read_timeout};
use crate::disk::io_admission::{DiskIoPermit, spawn_blocking_with_disk_io_permit};
use crate::disk::io_admission::{DiskIoPermit, current_resource_unit, spawn_blocking_with_disk_io_permit};
use crate::disk::{
BUCKET_META_PREFIX, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
CHECK_PART_VOLUME_NOT_FOUND, CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, DataDirDeleteStatus,
@@ -79,6 +79,7 @@ use metrics::counter;
#[cfg(target_os = "linux")]
use metrics::gauge;
use parking_lot::{Mutex as ParkingLotMutex, RwLock as ParkingLotRwLock};
use rustfs_concurrency::adaptive::AdaptivePermit;
use rustfs_filemeta::{
Cache, FileInfo, FileInfoOpts, FileMeta, MetaCacheEntry, MetacacheWriter, ObjectPartInfo, Opts, RawFileInfo, UpdateFn,
ValidationMode, get_file_info, read_xl_meta_no_data_sync,
@@ -314,6 +315,7 @@ enum MetadataReadOp {
}
struct MetadataReadJob {
resource_unit: Option<AdaptivePermit>,
path: PathBuf,
io_permit: Option<DiskIoPermit>,
op: MetadataReadOp,
@@ -335,6 +337,7 @@ impl MetadataReadJob {
if self.is_cancelled() {
return;
}
let _resource_unit = self.resource_unit;
let _io_permit = self.io_permit;
match self.op {
MetadataReadOp::ReadAll { tx } => {
@@ -394,7 +397,8 @@ impl MetadataReadQueue {
})
}
fn enqueue(self: &Arc<Self>, job: MetadataReadJob) {
fn enqueue(self: &Arc<Self>, mut job: MetadataReadJob) {
job.resource_unit = current_resource_unit();
let should_spawn = {
let mut state = self.state.lock();
state.jobs.push_back(job);
@@ -8252,6 +8256,7 @@ impl LocalDisk {
let path = file_path.as_ref().to_path_buf();
let (tx, rx) = tokio::sync::oneshot::channel();
self.metadata_read_queue.enqueue(MetadataReadJob {
resource_unit: None,
path,
io_permit,
op: MetadataReadOp::ReadMetadataOnly { tx },
@@ -8429,6 +8434,7 @@ impl LocalDisk {
let path = file_path.as_ref().to_path_buf();
let (tx, rx) = tokio::sync::oneshot::channel();
self.metadata_read_queue.enqueue(MetadataReadJob {
resource_unit: None,
path,
io_permit,
op: MetadataReadOp::ReadAll { tx },
@@ -25858,6 +25864,46 @@ mod test {
}
}
#[tokio::test]
async fn metadata_read_job_retains_resource_unit_until_native_completion() {
use crate::disk::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let dir = tempfile::tempdir().expect("metadata root");
let queue = MetadataReadQueue::new(os::PublicationRoot::new(dir.path()).expect("publication root"));
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();
with_background_resource_unit(gate.acquire().await, async {
queue.enqueue(MetadataReadJob {
resource_unit: None,
path: PathBuf::new(),
io_permit: None,
op: MetadataReadOp::TestCustom {
is_closed: Box::new(|| false),
run: Box::new(move || {
started_tx.send(()).expect("native metadata worker started");
release_rx.blocking_recv().expect("release native metadata work");
}),
},
});
started_rx.await.expect("worker owns queued job");
})
.await;
let held = gate.snapshot().active;
release_tx.send(()).expect("release native job after caller scope ends");
tokio::time::timeout(Duration::from_secs(5), async {
while gate.snapshot().active != 0 || queue.state.lock().active_workers != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("native metadata job and worker retire");
assert_eq!(held, 1, "leaving the caller scope must not release a live metadata job's unit");
assert!(gate.try_acquire().is_some());
}
#[tokio::test]
async fn metadata_read_queue_bounds_concurrency_skips_cancelled_and_recovers_from_panic() {
let dir = tempfile::tempdir().expect("tempdir should be created");
@@ -25878,6 +25924,7 @@ mod test {
let entered_tx = entered_tx.clone();
let release_rx = Arc::clone(&release_rx);
queue.enqueue(MetadataReadJob {
resource_unit: None,
path: PathBuf::new(),
io_permit: None,
op: MetadataReadOp::TestCustom {
@@ -25905,12 +25952,14 @@ mod test {
let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
drop(cancelled_rx);
queue.enqueue(MetadataReadJob {
resource_unit: None,
path: dir.path().join("cancelled-never-opened"),
io_permit: None,
op: MetadataReadOp::ReadMetadataOnly { tx: cancelled_tx },
});
let executed_cancelled_clone = Arc::clone(&executed_cancelled);
queue.enqueue(MetadataReadJob {
resource_unit: None,
path: PathBuf::new(),
io_permit: None,
op: MetadataReadOp::TestCustom {
@@ -25923,6 +25972,7 @@ mod test {
// Enqueue a panicking job followed by a normal completion job to verify panic respawn.
queue.enqueue(MetadataReadJob {
resource_unit: None,
path: PathBuf::new(),
io_permit: None,
op: MetadataReadOp::TestCustom {
@@ -25934,6 +25984,7 @@ mod test {
let active_trailing = Arc::clone(&active);
let max_active_trailing = Arc::clone(&max_active);
queue.enqueue(MetadataReadJob {
resource_unit: None,
path: PathBuf::new(),
io_permit: None,
op: MetadataReadOp::TestCustom {
+5 -2
View File
@@ -31,8 +31,11 @@ pub mod os;
#[cfg(test)]
pub(crate) use io_admission::DiskIoClass;
pub use io_admission::with_background_disk_io;
pub(crate) use io_admission::{acquire_context_disk_io_permit, current_disk_io_context, with_optional_disk_io_context};
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 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";
+418 -91
View File
@@ -759,9 +759,15 @@ impl OpenedDstDirFsyncGroup {
struct DstDirFsyncWaiter {
namespace_owner: Option<Arc<dyn Send + Sync>>,
resource_unit: Option<rustfs_concurrency::adaptive::AdaptivePermit>,
result_tx: oneshot::Sender<SharedDstDirFsyncResult>,
}
struct DstDirFsyncOwner {
_namespace: Option<Arc<dyn Send + Sync>>,
_resource: Option<rustfs_concurrency::adaptive::AdaptivePermit>,
}
#[derive(Clone)]
struct SharedDstDirFsyncError {
kind: io::ErrorKind,
@@ -851,6 +857,7 @@ impl DstDirFsyncGroupCommit {
group_state.pending.push_back(DstDirFsyncWaiter {
result_tx,
namespace_owner,
resource_unit: super::io_admission::current_resource_unit(),
});
let start_worker = !group_state.worker_running;
if start_worker {
@@ -907,7 +914,7 @@ impl DstDirFsyncGroupCommit {
}
#[cfg(unix)]
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Vec<Arc<dyn Send + Sync>>) -> io::Result<()> {
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Vec<DstDirFsyncOwner>) -> io::Result<()> {
#[cfg(test)]
let dir = group.dir.clone();
let dir_file = group.dir_file.clone();
@@ -930,7 +937,7 @@ async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Ve
}
#[cfg(not(unix))]
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Vec<Arc<dyn Send + Sync>>) -> io::Result<()> {
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Vec<DstDirFsyncOwner>) -> io::Result<()> {
let _namespace_owners = namespace_owners;
fsync_dir(&group.dir).await
}
@@ -997,7 +1004,17 @@ fn run_dst_dir_fsync_group_worker(group: Arc<DstDirFsyncGroup>) -> impl std::fut
#[cfg(test)]
fsync_dir_recorder::record_grouped(&group.dir, batch.len());
let namespace_owners = batch.iter_mut().filter_map(|waiter| waiter.namespace_owner.take()).collect();
let namespace_owners = batch
.iter_mut()
.filter_map(|waiter| {
let namespace = waiter.namespace_owner.take();
let resource = waiter.resource_unit.take();
(namespace.is_some() || resource.is_some()).then_some(DstDirFsyncOwner {
_namespace: namespace,
_resource: resource,
})
})
.collect();
let result = fsync_open_dst_dir_group(&group, namespace_owners)
.await
.map_err(SharedDstDirFsyncError::from_error);
@@ -1056,7 +1073,7 @@ async fn fsync_dst_dir_group_commit_opened(
}
let dir = dir.to_path_buf();
let opened = tokio::task::spawn_blocking(move || {
let opened = super::io_admission::spawn_blocking_with_disk_io_permit(None, move || {
#[cfg(target_os = "linux")]
if let Some(file) = opened {
return OpenedDstDirFsyncGroup::from_file(&dir, file);
@@ -1190,6 +1207,7 @@ type FileFdatasyncGroupKey = (usize, bool);
struct FileFdatasyncWaiter {
files: Vec<PathBuf>,
resource_unit: Option<rustfs_concurrency::adaptive::AdaptivePermit>,
enqueued_at: Option<Instant>,
wait_role: &'static str,
result_tx: oneshot::Sender<SharedFileFdatasyncResult>,
@@ -1260,7 +1278,8 @@ impl FileFdatasyncGroupCommit {
let (result_tx, result_rx) = oneshot::channel();
let key = (Arc::as_ptr(&disk_permits) as usize, FSYNC_ON_CURRENT_RUNTIME.get());
let mut registry = self.inner.lock();
registry.groups.retain(|_, group| group.disk_permits.strong_count() > 0);
// A retired limiter can still have queued or native work. Its worker
// owns the registry entry and returns that budget on completion/cancel.
if registry.total_waiters >= MAX_FILE_FDATASYNC_WAITERS {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
@@ -1300,6 +1319,7 @@ impl FileFdatasyncGroupCommit {
};
group_state.pending.push_back(FileFdatasyncWaiter {
files,
resource_unit: super::io_admission::current_resource_unit(),
enqueued_at: rustfs_io_metrics::put_stage_timer(),
wait_role,
result_tx,
@@ -1332,7 +1352,13 @@ impl FileFdatasyncGroupCommit {
fn remove_idle_group(&self, group: &Arc<FileFdatasyncGroup>) {
let mut registry = self.inner.lock();
let group_state = group.inner.lock();
if !group_state.worker_running && group_state.pending.is_empty() {
if !group_state.worker_running
&& group_state.pending.is_empty()
&& registry
.groups
.get(&group.key)
.is_some_and(|registered| Arc::ptr_eq(registered, group))
{
registry.groups.remove(&group.key);
}
}
@@ -1352,80 +1378,142 @@ impl FileFdatasyncGroupCommit {
}
}
async fn run_file_fdatasync_group_worker(group: Arc<FileFdatasyncGroup>) {
loop {
#[cfg(test)]
file_sync_probe::run_before_group_batch();
tokio::task::yield_now().await;
let wait = file_fdatasync_group_commit_wait();
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
let (batch, batch_file_count): (Vec<FileFdatasyncWaiter>, usize) = {
let mut group_state = group.inner.lock();
let batch_file_count = group_state.pending_files;
group_state.pending_files = 0;
(group_state.pending.drain(..).collect(), batch_file_count)
};
if batch.is_empty() {
let mut group_state = group.inner.lock();
group_state.worker_running = false;
drop(group_state);
FILE_FDATASYNC_GROUP_COMMIT.remove_idle_group(&group);
struct FileFdatasyncWorkerGuard {
group: Arc<FileFdatasyncGroup>,
in_flight_waiters: usize,
in_flight_files: usize,
armed: bool,
}
impl Drop for FileFdatasyncWorkerGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
rustfs_io_metrics::record_put_rename_fdatasync_group_outstanding(
rustfs_io_metrics::PUT_RENAME_FDATASYNC_GROUP_OUTSTANDING_STATE_BATCH_WAITERS,
batch.len(),
);
rustfs_io_metrics::record_put_rename_fdatasync_group_outstanding(
rustfs_io_metrics::PUT_RENAME_FDATASYNC_GROUP_OUTSTANDING_STATE_BATCH_FILES,
batch_file_count,
);
for waiter in &batch {
if let Some(enqueued_at) = waiter.enqueued_at {
rustfs_io_metrics::record_put_rename_fdatasync_group_wait(
waiter.wait_role,
enqueued_at.elapsed().as_secs_f64() * 1000.0,
);
let pending = {
// Same registry -> group order as enqueue and normal completion.
let mut registry = FILE_FDATASYNC_GROUP_COMMIT.inner.lock();
let mut state = self.group.inner.lock();
let pending = std::mem::take(&mut state.pending);
let pending_files = std::mem::take(&mut state.pending_files);
state.worker_running = false;
if registry
.groups
.get(&self.group.key)
.is_some_and(|group| Arc::ptr_eq(group, &self.group))
{
registry.total_waiters = registry.total_waiters.saturating_sub(pending.len() + self.in_flight_waiters);
registry.total_files = registry.total_files.saturating_sub(pending_files + self.in_flight_files);
registry.groups.remove(&self.group.key);
}
}
let batch_files: Vec<PathBuf> = batch.iter().flat_map(|waiter| waiter.files.iter().cloned()).collect();
#[cfg(test)]
file_sync_probe::record_group_batch(batch_file_count);
rustfs_io_metrics::record_put_rename_fdatasync_batch(
rustfs_io_metrics::PUT_RENAME_FDATASYNC_BATCH_MODE_PARALLEL,
batch_file_count,
);
let result = if let Some(disk_permits) = group.disk_permits.upgrade() {
run_file_sync_blocking(disk_permits, move || sync_files(&batch_files))
pending
};
// Queued work never reached the disk; native work retains its own units.
// Destructors and notification wakeups run outside the registry locks.
drop(pending);
}
}
fn run_file_fdatasync_group_worker(group: Arc<FileFdatasyncGroup>) -> impl std::future::Future<Output = ()> {
// Capture before spawning so even an unpolled worker releases its queue.
let worker_guard = FileFdatasyncWorkerGuard {
group: group.clone(),
in_flight_waiters: 0,
in_flight_files: 0,
armed: true,
};
async move {
let mut worker_guard = worker_guard;
loop {
#[cfg(test)]
file_sync_probe::run_before_group_batch();
tokio::task::yield_now().await;
let wait = file_fdatasync_group_commit_wait();
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
let (mut batch, batch_file_count): (Vec<FileFdatasyncWaiter>, usize) = {
let mut group_state = group.inner.lock();
let batch_file_count = group_state.pending_files;
group_state.pending_files = 0;
(group_state.pending.drain(..).collect(), batch_file_count)
};
if batch.is_empty() {
let mut group_state = group.inner.lock();
worker_guard.armed = false;
group_state.worker_running = false;
drop(group_state);
FILE_FDATASYNC_GROUP_COMMIT.remove_idle_group(&group);
return;
}
worker_guard.in_flight_waiters = batch.len();
worker_guard.in_flight_files = batch_file_count;
rustfs_io_metrics::record_put_rename_fdatasync_group_outstanding(
rustfs_io_metrics::PUT_RENAME_FDATASYNC_GROUP_OUTSTANDING_STATE_BATCH_WAITERS,
batch.len(),
);
rustfs_io_metrics::record_put_rename_fdatasync_group_outstanding(
rustfs_io_metrics::PUT_RENAME_FDATASYNC_GROUP_OUTSTANDING_STATE_BATCH_FILES,
batch_file_count,
);
for waiter in &batch {
if let Some(enqueued_at) = waiter.enqueued_at {
rustfs_io_metrics::record_put_rename_fdatasync_group_wait(
waiter.wait_role,
enqueued_at.elapsed().as_secs_f64() * 1000.0,
);
}
}
// Move each producer's existing path buffer and unit into the physical
// batch. No path cloning or separate resource-owner allocation is needed.
let batch_files: Vec<_> = batch
.iter_mut()
.map(|waiter| (std::mem::take(&mut waiter.files), waiter.resource_unit.take()))
.collect();
#[cfg(test)]
file_sync_probe::record_group_batch(batch_file_count);
rustfs_io_metrics::record_put_rename_fdatasync_batch(
rustfs_io_metrics::PUT_RENAME_FDATASYNC_BATCH_MODE_PARALLEL,
batch_file_count,
);
let result = if let Some(disk_permits) = group.disk_permits.upgrade() {
run_file_sync_blocking(disk_permits, move || {
for (files, _resource_unit) in &batch_files {
sync_files(files)?;
}
Ok(())
})
.await
.map_err(SharedFileFdatasyncError::from_error)
} else {
Err(SharedFileFdatasyncError::from_error(io::Error::other(
"file fdatasync group commit limiter dropped",
)))
};
FILE_FDATASYNC_GROUP_COMMIT.complete_batch(batch.len(), batch_file_count);
let should_stop = {
let mut group_state = group.inner.lock();
if group_state.pending.is_empty() {
group_state.worker_running = false;
true
} else {
false
Err(SharedFileFdatasyncError::from_error(io::Error::other(
"file fdatasync group commit limiter dropped",
)))
};
FILE_FDATASYNC_GROUP_COMMIT.complete_batch(batch.len(), batch_file_count);
worker_guard.in_flight_waiters = 0;
worker_guard.in_flight_files = 0;
let should_stop = {
let mut group_state = group.inner.lock();
if group_state.pending.is_empty() {
worker_guard.armed = false;
group_state.worker_running = false;
true
} else {
false
}
};
if should_stop {
FILE_FDATASYNC_GROUP_COMMIT.remove_idle_group(&group);
}
for waiter in batch {
let _ = waiter.result_tx.send(result.clone());
}
if should_stop {
return;
}
};
if should_stop {
FILE_FDATASYNC_GROUP_COMMIT.remove_idle_group(&group);
}
for waiter in batch {
let _ = waiter.result_tx.send(result.clone());
}
if should_stop {
return;
}
}
}
@@ -1513,6 +1601,11 @@ pub(super) fn is_isolated_io_thread() -> bool {
/// Spawn a blocking task on the fsync-dedicated runtime if configured,
/// otherwise fall back to the main tokio blocking pool.
fn fsync_spawn_blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> tokio::task::JoinHandle<T> {
let resource_unit = super::io_admission::current_resource_unit();
let f = move || {
let _resource_unit = resource_unit;
f()
};
if FSYNC_ON_CURRENT_RUNTIME.get() {
return tokio::task::spawn_blocking(f);
}
@@ -1693,7 +1786,10 @@ impl DiskBlockingQueue {
operation: impl FnOnce(&dyn Fn() -> bool) -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
let (tx, rx) = oneshot::channel();
let resource_unit = super::io_admission::current_resource_unit();
self.enqueue(Box::new(move || {
// Retain each producer separately, never on the shared worker.
let _resource_unit = resource_unit;
let result = operation(&|| tx.is_closed());
let _ = tx.send(result);
}));
@@ -7734,6 +7830,10 @@ mod tests {
#[tokio::test]
#[serial_test::serial(dst_dir_fsync_group_commit)]
async fn grouped_fsync_physical_batch_keeps_all_owners_after_worker_cancellation() {
use crate::disk::io_admission::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let resources: [_; 3] = std::array::from_fn(|_| AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1));
let temp_dir = tempdir().expect("fixture directory");
let dir = temp_dir.path().canonicalize().expect("canonical fsync path");
let first_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
@@ -7742,19 +7842,25 @@ mod tests {
let second_owner = second_ctx.begin_namespace_commit();
let first_probe = Arc::downgrade(&first_owner);
let second_probe = Arc::downgrade(&second_owner);
let (first_rx, group) = DST_DIR_FSYNC_GROUP_COMMIT
.enqueue_opened(
OpenedDstDirFsyncGroup::open(&dir).expect("open first waiter directory"),
Some(first_owner),
)
.expect("queue first real waiter");
let (first_rx, group) = with_background_resource_unit(resources[0].acquire().await, async {
DST_DIR_FSYNC_GROUP_COMMIT
.enqueue_opened(
OpenedDstDirFsyncGroup::open(&dir).expect("open first waiter directory"),
Some(first_owner),
)
.expect("queue first real waiter")
})
.await;
let group = group.expect("first waiter starts the group");
let (second_rx, second_worker) = DST_DIR_FSYNC_GROUP_COMMIT
.enqueue_opened(
OpenedDstDirFsyncGroup::open(&dir).expect("open second waiter directory"),
Some(second_owner),
)
.expect("queue second real waiter");
let (second_rx, second_worker) = with_background_resource_unit(resources[1].acquire().await, async {
DST_DIR_FSYNC_GROUP_COMMIT
.enqueue_opened(
OpenedDstDirFsyncGroup::open(&dir).expect("open second waiter directory"),
Some(second_owner),
)
.expect("queue second real waiter")
})
.await;
assert!(second_worker.is_none(), "same directory must join the same batch");
assert_eq!(group.inner.lock().pending.len(), 2);
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
@@ -7778,12 +7884,15 @@ mod tests {
let queued_owner = queued_ctx.begin_namespace_commit();
let queued_probe = Arc::downgrade(&queued_owner);
let queued_generation = queued_ctx.namespace_commit_generation();
let (queued_rx, queued_worker) = DST_DIR_FSYNC_GROUP_COMMIT
.enqueue_opened(
OpenedDstDirFsyncGroup::open(&dir).expect("open queued waiter directory"),
Some(queued_owner),
)
.expect("queue a waiter after the physical batch was frozen");
let (queued_rx, queued_worker) = with_background_resource_unit(resources[2].acquire().await, async {
DST_DIR_FSYNC_GROUP_COMMIT
.enqueue_opened(
OpenedDstDirFsyncGroup::open(&dir).expect("open queued waiter directory"),
Some(queued_owner),
)
.expect("queue a waiter after the physical batch was frozen")
})
.await;
assert!(queued_worker.is_none());
assert_eq!(group.inner.lock().pending.len(), 1);
drop((first_rx, second_rx));
@@ -7798,6 +7907,7 @@ mod tests {
assert_eq!(DST_DIR_FSYNC_GROUP_COMMIT.counts_for_test(), (0, 0));
let first_pending = first_ctx.namespace_commits_pending() && first_probe.upgrade().is_some();
let second_pending = second_ctx.namespace_commits_pending() && second_probe.upgrade().is_some();
let retained_resources = resources.each_ref().map(|gate| gate.snapshot().active);
let generations = (first_ctx.namespace_commit_generation(), second_ctx.namespace_commit_generation());
drop(release_tx);
tokio::time::timeout(Duration::from_secs(5), async {
@@ -7812,6 +7922,12 @@ mod tests {
})
.await
.expect("physical fsync must release every batch owner");
assert_eq!(
retained_resources,
[1, 1, 0],
"physical batch retains each producer; queued cancellation releases its unit"
);
assert_eq!(resources.each_ref().map(|gate| gate.snapshot().active), [0; 3]);
assert!(fsync_dir_recorder::was_fsynced(&dir), "the detached syscall must really execute");
assert!(
first_pending && second_pending,
@@ -8794,6 +8910,217 @@ mod tests {
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[tokio::test]
#[serial_test::serial(file_sync_probe)]
async fn resource_grouped_fdatasync_retired_limiter_keeps_its_budget_until_worker_cleanup() {
use crate::disk::io_admission::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let retired = Arc::new(Semaphore::new(1));
let (old_rx, old_group) = with_background_resource_unit(gate.acquire().await, async {
FILE_FDATASYNC_GROUP_COMMIT.enqueue(retired.clone(), vec![PathBuf::from("retired")])
})
.await
.expect("enqueue retiring producer");
let old_worker = run_file_fdatasync_group_worker(old_group.expect("retiring group"));
drop(retired);
let live = Arc::new(Semaphore::new(1));
let (new_rx, new_group) = FILE_FDATASYNC_GROUP_COMMIT
.enqueue(live, vec![PathBuf::from("successor")])
.expect("enqueue new producer");
let new_worker = run_file_fdatasync_group_worker(new_group.expect("new group"));
let before_cleanup = FILE_FDATASYNC_GROUP_COMMIT.counts_for_test();
drop(old_worker);
let after_old = FILE_FDATASYNC_GROUP_COMMIT.counts_for_test();
drop(new_worker);
let after_both = FILE_FDATASYNC_GROUP_COMMIT.counts_for_test();
clear_file_fdatasync_group_commit_for_test();
assert!(old_rx.await.is_err());
assert!(new_rx.await.is_err());
assert_eq!(gate.snapshot().active, 0);
assert_eq!(before_cleanup, (2, 2, 2), "retiring queued work still owns a registry budget");
assert_eq!(after_old, (1, 1, 1), "old cleanup must return exactly its own budget");
assert_eq!(after_both, (0, 0, 0), "no phantom waiters or files after cancellation");
}
#[tokio::test]
#[serial_test::serial(file_sync_probe)]
async fn resource_grouped_fdatasync_unpolled_worker_releases_its_producer() {
use crate::disk::io_admission::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let limiter = Arc::new(Semaphore::new(1));
let (rx, group) = with_background_resource_unit(gate.acquire().await, async {
FILE_FDATASYNC_GROUP_COMMIT.enqueue(limiter, vec![PathBuf::from("never-dispatched")])
})
.await
.expect("enqueue producer");
let group = group.expect("first producer starts group");
let held_before_drop = gate.snapshot().active;
drop(run_file_fdatasync_group_worker(group.clone()));
let counts = FILE_FDATASYNC_GROUP_COMMIT.counts_for_test();
let held_after_drop = gate.snapshot().active;
let pending = group.inner.lock().pending.len();
let running = group.inner.lock().worker_running;
// Also drain a failing implementation before making assertions.
drop(std::mem::take(&mut group.inner.lock().pending));
clear_file_fdatasync_group_commit_for_test();
assert!(rx.await.is_err());
assert_eq!((held_before_drop, held_after_drop), (1, 0));
assert_eq!(counts, (0, 0, 0));
assert_eq!((pending, running), (0, false));
}
#[tokio::test]
async fn file_fdatasync_stale_idle_cleanup_preserves_the_successor_group() {
let registry = FileFdatasyncGroupCommit::default();
let limiter = Arc::new(Semaphore::new(1));
let (first_rx, old) = registry
.enqueue(limiter.clone(), vec![PathBuf::from("old")])
.expect("first group");
let old = old.expect("first group starts worker");
let first_waiter = old.inner.lock().pending.pop_front().expect("freeze first batch");
old.inner.lock().pending_files = 0;
registry.complete_batch(1, 1);
old.inner.lock().worker_running = false;
registry.remove_idle_group(&old);
assert_eq!(registry.counts_for_test(), (0, 0, 0));
let (mut next_rx, next) = registry
.enqueue(limiter, vec![PathBuf::from("new")])
.expect("successor group");
let next = next.expect("successor starts worker");
assert!(!Arc::ptr_eq(&old, &next));
// An older worker can resume cleanup after a successor was installed.
registry.remove_idle_group(&old);
assert_eq!(registry.counts_for_test(), (1, 1, 1));
assert!(
registry
.inner
.lock()
.groups
.get(&next.key)
.is_some_and(|group| Arc::ptr_eq(group, &next))
);
assert!(matches!(next_rx.try_recv(), Err(oneshot::error::TryRecvError::Empty)));
assert!(next.inner.lock().worker_running);
assert_eq!(next.inner.lock().pending.len(), 1);
assert!(first_waiter.result_tx.send(Ok(())).is_ok());
assert!(first_rx.await.expect("original result").is_ok());
}
#[tokio::test]
#[serial_test::serial(file_sync_probe)]
async fn resource_grouped_fdatasync_keeps_native_producers_and_releases_cancelled_queue() {
use crate::disk::io_admission::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let resources: [_; 3] = std::array::from_fn(|_| AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1));
let root = tempdir().expect("file sync fixture");
let files: [_; 3] = std::array::from_fn(|index| root.path().join(index.to_string()));
for file in &files {
std::fs::write(file, b"durable payload").expect("write real file");
}
let _probe = file_sync_probe::set_blocking(root.path());
let limiter = Arc::new(Semaphore::new(1));
let mut receivers = Vec::new();
let mut group = None;
for index in 0..2 {
let (rx, worker) = with_background_resource_unit(resources[index].acquire().await, async {
FILE_FDATASYNC_GROUP_COMMIT.enqueue(limiter.clone(), vec![files[index].clone()])
})
.await
.expect("enqueue producer");
receivers.push(rx);
if index == 0 {
group = worker;
} else {
assert!(worker.is_none(), "second producer joins the same batch");
}
}
let group = group.expect("first producer starts worker");
let worker = tokio::spawn(run_file_fdatasync_group_worker(group.clone()));
file_sync_probe::wait_for_active(1).await;
let (queued, new_worker) = with_background_resource_unit(resources[2].acquire().await, async {
FILE_FDATASYNC_GROUP_COMMIT.enqueue(limiter.clone(), vec![files[2].clone()])
})
.await
.expect("enqueue after batch is frozen");
assert!(new_worker.is_none());
worker.abort();
assert!(worker.await.expect_err("cancel group owner").is_cancelled());
let retained = resources.each_ref().map(|gate| gate.snapshot().active);
let counts = FILE_FDATASYNC_GROUP_COMMIT.counts_for_test();
let (pending, running) = {
let state = group.inner.lock();
(state.pending.len(), state.worker_running)
};
// Preserve failure observations, then clean up even the old implementation.
let remaining = std::mem::take(&mut group.inner.lock().pending);
drop(remaining);
clear_file_fdatasync_group_commit_for_test();
assert!(queued.await.is_err());
file_sync_probe::release();
file_sync_probe::wait_for_idle().await;
let permit = limiter.acquire().await.expect("native disk permit returned");
drop(permit);
drop(receivers);
assert_eq!(retained, [1, 1, 0], "native owners survive, queued producer is released");
assert_eq!(resources.each_ref().map(|gate| gate.snapshot().active), [0; 3]);
assert_eq!(counts, (0, 0, 0), "cancelled worker clears its registry budget");
assert_eq!((pending, running), (0, false));
assert_eq!(file_sync_probe::attempts(), 2, "undispatched third file must not be synced");
}
#[tokio::test]
async fn resource_native_dispatch_retains_each_unit_after_caller_cancellation() {
use crate::disk::io_admission::with_background_resource_unit;
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let mut retained = Vec::new();
for queued in [false, true] {
let gate = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let unit = gate.acquire().await;
let (started_tx, started_rx) = oneshot::channel();
let (release_tx, release_rx) = oneshot::channel();
let (finished_tx, finished_rx) = oneshot::channel();
let worker = tokio::spawn(with_background_resource_unit(unit, async move {
let operation = move || {
started_tx.send(()).expect("native operation entered");
let _ = release_rx.blocking_recv();
let _ = finished_tx.send(());
Ok(())
};
if queued {
DiskBlockingQueue::new().run(operation).await
} else {
fsync_spawn_blocking(operation).await.expect("native operation joined")
}
}));
started_rx.await.expect("native operation started");
worker.abort();
assert!(worker.await.expect_err("caller cancelled").is_cancelled());
retained.push(gate.snapshot().active);
// Drain native work before assertions, including in the negative control.
release_tx.send(()).expect("release native operation");
finished_rx.await.expect("native operation finished");
tokio::time::timeout(Duration::from_secs(1), async {
while gate.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("native completion returns the unit");
}
assert_eq!(retained, [1, 1], "fsync and namespace dispatch must retain their own producer's unit");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn disk_blocking_queue_bounds_concurrency_and_recovers_from_panic() {
use std::sync::atomic::{AtomicUsize, Ordering};
+1 -1
View File
@@ -55,7 +55,7 @@ mod set_disk;
mod storage_api_contracts;
mod store;
pub use disk::with_background_disk_io;
pub use disk::{with_background_disk_io, with_background_resource_unit};
pub use store::PoolMetaWriteGateStatus;
// pub mod checksum;
+31 -11
View File
@@ -14,7 +14,7 @@
use super::*;
use crate::core::pools::{POOL_META_NAME, load_pool_meta_identity_observing};
use crate::disk::{self, error::DiskError};
use crate::disk::{self, current_resource_unit, error::DiskError, with_optional_resource_unit};
use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
use crate::set_disk::get_lock_acquire_timeout;
use crate::storage_api_contracts::heal::HealOperations as _;
@@ -301,12 +301,15 @@ impl ECStore {
let opts = *opts;
// Dropping a caller's cancellation/timeout waiter must not release the lifecycle
// owner while a storage operation is still committing.
tokio::spawn(BUCKET_HEAL_SCOPE.scope(scope.clone(), async move {
scope.check().map_err(Error::from)?;
let result = operation(store, bucket, opts).await;
scope.check().map_err(Error::from)?;
result
}))
tokio::spawn(with_optional_resource_unit(
current_resource_unit(),
BUCKET_HEAL_SCOPE.scope(scope.clone(), async move {
scope.check().map_err(Error::from)?;
let result = operation(store, bucket, opts).await;
scope.check().map_err(Error::from)?;
result
}),
))
.await
.map_err(|error| Error::other_with_context("bucket heal owner task failed", error))?
}
@@ -1953,6 +1956,11 @@ mod tests {
#[tokio::test]
#[serial_test::serial]
async fn bucket_incarnation_heal_owner_survives_cancelled_waiter_and_queued_writer() {
use crate::disk::{current_resource_unit, with_background_resource_unit};
use rustfs_concurrency::adaptive::AdaptiveAdmission;
use std::num::NonZeroU16;
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one unit"), 1);
let (_root, store, shutdown) = multi_pool_heal_store().await;
let bucket = format!("heal-owner-{}", Uuid::new_v4().simple());
store
@@ -1965,14 +1973,16 @@ mod tests {
let (drained_tx, drained_rx) = tokio::sync::oneshot::channel();
let worker_store = store.clone();
let worker_bucket = bucket.clone();
let waiter = tokio::spawn(async move {
let waiter = tokio::spawn(with_background_resource_unit(resources.acquire().await, async move {
worker_store
.run_bucket_heal_at_incarnation(
&worker_bucket,
identity,
&HealOpts::default(),
move |store, bucket, opts| async move {
started_tx.send(()).expect("announce held generation");
started_tx
.send(current_resource_unit().is_some())
.expect("announce held generation");
release_rx.await.expect("release commit");
let result = store
.heal_object_with_proof(&bucket, "history.txt", &Uuid::new_v4().to_string(), &opts)
@@ -1985,10 +1995,11 @@ mod tests {
},
)
.await
});
started_rx.await.expect("operation acquired generation fence");
}));
let inherited = started_rx.await.expect("operation acquired generation fence");
waiter.abort();
assert!(waiter.await.expect_err("waiter aborted").is_cancelled());
let held = resources.snapshot().active;
let writer_store = store.clone();
let writer_bucket = bucket.clone();
let mut writer = Box::pin(writer_store.acquire_bucket_lifecycle_write_lock(&writer_bucket));
@@ -2007,6 +2018,15 @@ mod tests {
.expect("lifecycle writer");
drop(guard);
shutdown.cancel();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while resources.snapshot().active != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("drained heal releases its resource unit");
assert!(inherited, "owned bucket heal must inherit resource ownership");
assert_eq!(held, 1, "cancelled waiter must not release a live heal's resource unit");
}
async fn minimal_heal_store() -> ECStore {