mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-21 20:06:37 +00:00
d60a77b750
* fix(quota): enforce durable hard quota reservations * fix(quota): close reservation bypasses * fix(quota): isolate tests and box object futures * fix(quota): close legacy and deferred settlement bypasses * fix(app): keep object futures off caller stacks * fix(metrics): preserve object operation labels * fix(logging): retain GET trace guard contract
1114 lines
41 KiB
Rust
1114 lines
41 KiB
Rust
// 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 crate::bucket::metadata_sys;
|
|
use crate::config::com::{CONFIG_PREFIX, read_config_no_lock, save_config_with_opts};
|
|
use crate::data_usage::compute_bucket_usage;
|
|
use crate::disk::RUSTFS_META_BUCKET;
|
|
use crate::disk::{DiskAPI, error::DiskError};
|
|
use crate::error::{Result, StorageError, is_err_object_not_found, is_err_version_not_found};
|
|
use crate::object_api::{ObjectInfo, ObjectOptions, QuotaAdmission};
|
|
use crate::set_disk::{SetDisks, get_lock_acquire_timeout};
|
|
use crate::storage_api_contracts::namespace::NamespaceLocking;
|
|
use crate::storage_api_contracts::object::ObjectOperations;
|
|
use crate::store::ECStore;
|
|
use futures::{StreamExt, stream};
|
|
use rustfs_lock::NamespaceLockGuard;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use time::OffsetDateTime;
|
|
use tracing::warn;
|
|
use uuid::Uuid;
|
|
|
|
const QUOTA_LEDGER_FORMAT_VERSION: u8 = 1;
|
|
const MAX_ORPHANS_REAPED_PER_WRITE: usize = 64;
|
|
const MAX_ORPHAN_PROBES_PER_WRITE: usize = 128;
|
|
const ORPHAN_PROBE_CONCURRENCY: usize = 32;
|
|
const EVENT_QUOTA_LEDGER_SETTLEMENT: &str = "quota_ledger_settlement";
|
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
|
const LOG_SUBSYSTEM_QUOTA: &str = "quota";
|
|
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
static FAIL_NEXT_LEDGER_SAVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
|
|
// Lock order: caller-held destination object/upload, bucket metadata
|
|
// transaction (read), operation reservation, then quota ledger.
|
|
|
|
#[cfg(not(any(test, feature = "test-util")))]
|
|
const ORPHAN_MIN_AGE_SECONDS: i64 = 30;
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
const ORPHAN_MIN_AGE_SECONDS: i64 = 0;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct PersistedReservation {
|
|
object: String,
|
|
old_size: u64,
|
|
new_size: u64,
|
|
created_at: i64,
|
|
#[serde(default)]
|
|
pool_index: Option<usize>,
|
|
#[serde(default)]
|
|
set_index: Option<usize>,
|
|
#[serde(default)]
|
|
commit_started: bool,
|
|
}
|
|
|
|
impl PersistedReservation {
|
|
fn growth(&self) -> u64 {
|
|
self.new_size.saturating_sub(self.old_size)
|
|
}
|
|
|
|
fn target(&self) -> Option<(usize, usize)> {
|
|
self.pool_index.zip(self.set_index)
|
|
}
|
|
|
|
fn matches_expected(&self, expected: &Self) -> bool {
|
|
self.object == expected.object && self.old_size == expected.old_size && self.new_size == expected.new_size
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct QuotaLedger {
|
|
version: u8,
|
|
bucket_incarnation: Uuid,
|
|
quota_revision_unix_nanos: i128,
|
|
accounted_usage: u64,
|
|
reservations: BTreeMap<Uuid, PersistedReservation>,
|
|
#[serde(default)]
|
|
reconcile_required: bool,
|
|
#[serde(default)]
|
|
reap_cursor: Option<Uuid>,
|
|
}
|
|
|
|
impl QuotaLedger {
|
|
fn new(bucket_incarnation: Uuid, quota_revision: OffsetDateTime, accounted_usage: u64) -> Self {
|
|
Self {
|
|
version: QUOTA_LEDGER_FORMAT_VERSION,
|
|
bucket_incarnation,
|
|
quota_revision_unix_nanos: quota_revision.unix_timestamp_nanos(),
|
|
accounted_usage,
|
|
reservations: BTreeMap::new(),
|
|
reconcile_required: false,
|
|
reap_cursor: None,
|
|
}
|
|
}
|
|
|
|
fn matches(&self, bucket_incarnation: Uuid, quota_revision: OffsetDateTime) -> bool {
|
|
self.bucket_incarnation == bucket_incarnation && self.quota_revision_unix_nanos == quota_revision.unix_timestamp_nanos()
|
|
}
|
|
|
|
fn admitted_usage(&self) -> Result<u64> {
|
|
let reserved_growth = self.reservations.values().try_fold(0_u64, |total, reservation| {
|
|
total
|
|
.checked_add(reservation.growth())
|
|
.ok_or(StorageError::PartMissingOrCorrupt)
|
|
})?;
|
|
if reserved_growth > self.accounted_usage {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
Ok(self.accounted_usage)
|
|
}
|
|
|
|
fn reserve(&mut self, operation_id: Uuid, reservation: PersistedReservation) -> Result<()> {
|
|
self.accounted_usage = self
|
|
.accounted_usage
|
|
.checked_add(reservation.growth())
|
|
.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
self.reservations.insert(operation_id, reservation);
|
|
Ok(())
|
|
}
|
|
|
|
fn commit(&mut self, operation_id: Uuid, expected: &PersistedReservation) -> Result<()> {
|
|
let Some(reservation) = self.reservations.remove(&operation_id) else {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
};
|
|
if !reservation.matches_expected(expected) {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
if reservation.new_size < reservation.old_size {
|
|
self.reconcile_required = true;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn abort(&mut self, operation_id: Uuid, expected: &PersistedReservation) -> Result<()> {
|
|
let Some(reservation) = self.reservations.remove(&operation_id) else {
|
|
return Ok(());
|
|
};
|
|
if !reservation.matches_expected(expected) {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
self.accounted_usage = self
|
|
.accounted_usage
|
|
.checked_sub(reservation.growth())
|
|
.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn mark_commit_started(&mut self, operation_id: Uuid, expected: &PersistedReservation) -> Result<()> {
|
|
let reservation = self
|
|
.reservations
|
|
.get_mut(&operation_id)
|
|
.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
if !reservation.matches_expected(expected) {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
reservation.commit_started = true;
|
|
Ok(())
|
|
}
|
|
|
|
fn should_reconcile_after_denial(&self) -> bool {
|
|
self.reservations.is_empty()
|
|
}
|
|
|
|
fn reap_candidates(&self, now: i64) -> (Vec<Uuid>, Option<Uuid>) {
|
|
let aged = self
|
|
.reservations
|
|
.iter()
|
|
.filter(|(_, reservation)| {
|
|
reservation.created_at > now || now.saturating_sub(reservation.created_at) >= ORPHAN_MIN_AGE_SECONDS
|
|
})
|
|
.map(|(operation_id, _)| *operation_id)
|
|
.collect::<Vec<_>>();
|
|
let start = self
|
|
.reap_cursor
|
|
.and_then(|cursor| aged.iter().position(|operation_id| *operation_id > cursor))
|
|
.unwrap_or(0);
|
|
let candidates = aged
|
|
.iter()
|
|
.cycle()
|
|
.skip(start)
|
|
.take(aged.len().min(MAX_ORPHAN_PROBES_PER_WRITE))
|
|
.copied()
|
|
.collect::<Vec<_>>();
|
|
let next_cursor = candidates.last().copied();
|
|
(candidates, next_cursor)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct QuotaContext {
|
|
store: Option<Arc<ECStore>>,
|
|
bucket: String,
|
|
object: String,
|
|
ledger_object: String,
|
|
bucket_incarnation: Option<Uuid>,
|
|
quota_revision: Option<OffsetDateTime>,
|
|
quota_limit: Option<u64>,
|
|
capability_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
|
snapshot_admission: Option<QuotaAdmission>,
|
|
legacy_data_movement: bool,
|
|
metadata_guard: Option<NamespaceLockGuard>,
|
|
pool_index: Option<usize>,
|
|
set_index: Option<usize>,
|
|
}
|
|
|
|
impl QuotaContext {
|
|
pub(crate) fn is_enforced(&self) -> bool {
|
|
self.quota_limit.is_some()
|
|
}
|
|
|
|
pub(crate) async fn reserve(self, old_size: u64, new_size: u64) -> Result<QuotaReservation> {
|
|
let Some(quota_limit) = self.quota_limit else {
|
|
return Ok(QuotaReservation::unlimited(self.metadata_guard));
|
|
};
|
|
if let Some(admission) = self.snapshot_admission {
|
|
let growth = new_size.saturating_sub(old_size);
|
|
if growth > admission.remaining() {
|
|
return Err(StorageError::QuotaExceeded {
|
|
current: admission.current_usage(),
|
|
limit: admission.quota_limit(),
|
|
});
|
|
}
|
|
return Ok(QuotaReservation::unlimited(self.metadata_guard));
|
|
}
|
|
if self.legacy_data_movement {
|
|
if new_size > old_size {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
return Ok(QuotaReservation::unlimited(self.metadata_guard));
|
|
}
|
|
let store = self.store.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
let bucket_incarnation = self.bucket_incarnation.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
let quota_revision = self.quota_revision.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
let operation_id = Uuid::new_v4();
|
|
let operation_lock_object = operation_lock_object(&self.ledger_object, operation_id);
|
|
let operation_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &operation_lock_object).await?;
|
|
let operation_guard = operation_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
let reservation = PersistedReservation {
|
|
object: self.object,
|
|
old_size,
|
|
new_size,
|
|
created_at: OffsetDateTime::now_utc().unix_timestamp(),
|
|
pool_index: self.pool_index,
|
|
set_index: self.set_index,
|
|
commit_started: false,
|
|
};
|
|
let ledger_data = LedgerReservationData {
|
|
store: Arc::clone(&store),
|
|
bucket: self.bucket,
|
|
ledger_object: self.ledger_object,
|
|
operation_id,
|
|
reservation: reservation.clone(),
|
|
};
|
|
let metadata_guard = self.metadata_guard;
|
|
let capability_proof = self.capability_proof;
|
|
|
|
tokio::spawn(async move {
|
|
reap_stale_reservations(Arc::clone(&store), &ledger_data.bucket, &ledger_data.ledger_object).await?;
|
|
|
|
let ledger_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &ledger_data.ledger_object).await?;
|
|
let ledger_guard = ledger_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
fence_namespace_mutations(&store, RUSTFS_META_BUCKET, &ledger_data.ledger_object, None).await?;
|
|
let mut ledger = load_current_ledger_locked(
|
|
Arc::clone(&store),
|
|
&ledger_data.bucket,
|
|
&ledger_data.ledger_object,
|
|
bucket_incarnation,
|
|
quota_revision,
|
|
)
|
|
.await?;
|
|
|
|
let growth = reservation.growth();
|
|
let mut current_usage = ledger.admitted_usage()?;
|
|
let mut expected_usage = current_usage.checked_add(growth).ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
if growth > 0 && expected_usage > quota_limit && growth <= quota_limit && ledger.should_reconcile_after_denial() {
|
|
reconcile_exact(&store, &ledger_data.bucket, &mut ledger).await?;
|
|
current_usage = ledger.admitted_usage()?;
|
|
expected_usage = current_usage.checked_add(growth).ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
}
|
|
if growth > 0 && expected_usage > quota_limit {
|
|
return Err(StorageError::QuotaExceeded {
|
|
current: current_usage,
|
|
limit: quota_limit,
|
|
});
|
|
}
|
|
if operation_guard.is_lock_lost() || metadata_guard.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost) {
|
|
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
mode: "quota_reservation",
|
|
bucket: ledger_data.bucket.clone(),
|
|
object: ledger_data.ledger_object.clone(),
|
|
required: 1,
|
|
achieved: 0,
|
|
});
|
|
}
|
|
ledger.reserve(operation_id, reservation)?;
|
|
save_ledger_locked(Arc::clone(&store), &ledger_data.ledger_object, &ledger, &ledger_guard).await?;
|
|
|
|
Ok(QuotaReservation {
|
|
ledger: Some(ledger_data),
|
|
operation_guard: Some(operation_guard),
|
|
metadata_guard,
|
|
capability_proof,
|
|
state: ReservationState::Pending,
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|err| StorageError::other(format!("quota ledger reservation task failed: {err}")))?
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct LedgerReservationData {
|
|
store: Arc<ECStore>,
|
|
bucket: String,
|
|
ledger_object: String,
|
|
operation_id: Uuid,
|
|
reservation: PersistedReservation,
|
|
}
|
|
|
|
pub(crate) struct QuotaReservation {
|
|
ledger: Option<LedgerReservationData>,
|
|
operation_guard: Option<NamespaceLockGuard>,
|
|
metadata_guard: Option<NamespaceLockGuard>,
|
|
capability_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
|
|
state: ReservationState,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ReservationState {
|
|
Pending,
|
|
CommitStarted,
|
|
Committed,
|
|
FenceReleaseUncertain,
|
|
}
|
|
|
|
impl QuotaReservation {
|
|
fn unlimited(metadata_guard: Option<NamespaceLockGuard>) -> Self {
|
|
Self {
|
|
ledger: None,
|
|
operation_guard: None,
|
|
metadata_guard,
|
|
capability_proof: None,
|
|
state: ReservationState::Pending,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_lock_lost(&self) -> bool {
|
|
self.operation_guard.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
|
|| self.metadata_guard.as_ref().is_some_and(NamespaceLockGuard::is_lock_lost)
|
|
}
|
|
|
|
pub(crate) fn capability_proof_matches(&self) -> bool {
|
|
self.capability_proof
|
|
.as_ref()
|
|
.is_none_or(crate::services::notification_sys::cross_pool_fence_fleet_proof_matches)
|
|
}
|
|
|
|
pub(crate) async fn mark_commit_started(&mut self) -> Result<()> {
|
|
if !self.capability_proof_matches() {
|
|
let ledger = self.ledger.as_ref().ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
return Err(quota_capability_error(&ledger.bucket, &ledger.ledger_object));
|
|
}
|
|
if let Some(ledger) = self.ledger.as_ref() {
|
|
mark_commit_started(ledger).await?;
|
|
}
|
|
self.state = ReservationState::CommitStarted;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn commit(mut self) {
|
|
self.state = ReservationState::Committed;
|
|
let Some(ledger) = self.ledger.as_ref() else {
|
|
return;
|
|
};
|
|
crate::store::list_objects::observe_list_objects_mutation(&ledger.store, &ledger.bucket).await;
|
|
match settle(ledger, true).await {
|
|
Ok(()) => self.ledger = None,
|
|
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn abort(mut self) {
|
|
let Some(ledger) = self.ledger.as_ref() else {
|
|
return;
|
|
};
|
|
match settle(ledger, false).await {
|
|
Ok(()) => self.ledger = None,
|
|
Err(err) => log_deferred_settlement(ledger, "abort_deferred", &err),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn defer_after_fence(mut self) {
|
|
self.state = ReservationState::FenceReleaseUncertain;
|
|
}
|
|
}
|
|
|
|
fn should_settle_on_drop(state: ReservationState) -> bool {
|
|
!matches!(state, ReservationState::CommitStarted | ReservationState::FenceReleaseUncertain)
|
|
}
|
|
|
|
impl Drop for QuotaReservation {
|
|
fn drop(&mut self) {
|
|
let Some(ledger) = self.ledger.take() else {
|
|
return;
|
|
};
|
|
if !should_settle_on_drop(self.state) {
|
|
return;
|
|
}
|
|
let committed = matches!(self.state, ReservationState::Committed);
|
|
let operation_guard = self.operation_guard.take();
|
|
let metadata_guard = self.metadata_guard.take();
|
|
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
|
return;
|
|
};
|
|
runtime.spawn(async move {
|
|
let _operation_guard = operation_guard;
|
|
let _metadata_guard = metadata_guard;
|
|
if let Err(err) = settle(&ledger, committed).await {
|
|
log_deferred_settlement(&ledger, "background_retry_failed", &err);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn begin(
|
|
ctx: &crate::runtime::instance::InstanceContext,
|
|
bucket: &str,
|
|
object: &str,
|
|
snapshot_admission: Option<QuotaAdmission>,
|
|
data_movement: bool,
|
|
pool_index: usize,
|
|
set_index: usize,
|
|
) -> Result<QuotaContext> {
|
|
if crate::bucket::utils::is_meta_bucketname(bucket) {
|
|
return Ok(QuotaContext {
|
|
store: None,
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
ledger_object: ledger_object(bucket),
|
|
bucket_incarnation: None,
|
|
quota_revision: None,
|
|
quota_limit: None,
|
|
capability_proof: None,
|
|
snapshot_admission: None,
|
|
legacy_data_movement: false,
|
|
metadata_guard: None,
|
|
pool_index: None,
|
|
set_index: None,
|
|
});
|
|
}
|
|
#[cfg(test)]
|
|
if let Some(snapshot_admission) = snapshot_admission {
|
|
return Ok(QuotaContext {
|
|
store: None,
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
ledger_object: ledger_object(bucket),
|
|
bucket_incarnation: None,
|
|
quota_revision: None,
|
|
quota_limit: Some(snapshot_admission.quota_limit()),
|
|
capability_proof: None,
|
|
snapshot_admission: Some(snapshot_admission),
|
|
legacy_data_movement: false,
|
|
metadata_guard: None,
|
|
pool_index: Some(pool_index),
|
|
set_index: Some(set_index),
|
|
});
|
|
}
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
if ctx.bucket_metadata_sys().is_none() {
|
|
return Ok(QuotaContext {
|
|
store: None,
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
ledger_object: ledger_object(bucket),
|
|
bucket_incarnation: None,
|
|
quota_revision: None,
|
|
quota_limit: None,
|
|
capability_proof: None,
|
|
snapshot_admission: None,
|
|
legacy_data_movement: false,
|
|
metadata_guard: None,
|
|
pool_index: None,
|
|
set_index: None,
|
|
});
|
|
}
|
|
|
|
let metadata_guard = metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(ctx, bucket).await?;
|
|
let (quota, bucket_incarnation, quota_revision) =
|
|
metadata_sys::get_quota_config_and_incarnation_from_disk_in(ctx, bucket).await?;
|
|
if metadata_guard.is_lock_lost() {
|
|
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
mode: "quota_config",
|
|
bucket: bucket.to_string(),
|
|
object: ledger_object(bucket),
|
|
required: 1,
|
|
achieved: 0,
|
|
});
|
|
}
|
|
if quota
|
|
.as_ref()
|
|
.is_some_and(|quota| quota.has_unsupported_reservation_protocol())
|
|
{
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
let durable_quota = quota.as_ref().filter(|quota| quota.uses_durable_reservations());
|
|
let capability_proof = if durable_quota.is_some() {
|
|
Some(
|
|
crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof()
|
|
.ok_or_else(|| quota_capability_error(bucket, &ledger_object(bucket)))?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let durable_quota_limit = durable_quota.and_then(|quota| quota.quota);
|
|
let snapshot_admission = match quota.as_ref().filter(|quota| !quota.uses_durable_reservations()) {
|
|
Some(quota) => match (quota.quota, snapshot_admission) {
|
|
(Some(limit), Some(admission)) if admission.quota_limit() == limit => Some(admission),
|
|
(Some(_), None) if data_movement => None,
|
|
(Some(_), _) => return Err(StorageError::PartMissingOrCorrupt),
|
|
(None, _) => None,
|
|
},
|
|
None => None,
|
|
};
|
|
let legacy_data_movement = durable_quota_limit.is_none()
|
|
&& quota.as_ref().and_then(|quota| quota.quota).is_some()
|
|
&& snapshot_admission.is_none()
|
|
&& data_movement;
|
|
let quota_limit = durable_quota_limit
|
|
.or_else(|| snapshot_admission.map(QuotaAdmission::quota_limit))
|
|
.or_else(|| {
|
|
legacy_data_movement
|
|
.then(|| quota.as_ref().and_then(|quota| quota.quota))
|
|
.flatten()
|
|
});
|
|
let store = if durable_quota_limit.is_some() {
|
|
Some(metadata_sys::object_store_in(ctx).await?)
|
|
} else {
|
|
None
|
|
};
|
|
Ok(QuotaContext {
|
|
store,
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
ledger_object: ledger_object(bucket),
|
|
bucket_incarnation: Some(bucket_incarnation),
|
|
quota_revision: Some(quota_revision),
|
|
quota_limit,
|
|
capability_proof,
|
|
snapshot_admission,
|
|
legacy_data_movement,
|
|
metadata_guard: Some(metadata_guard),
|
|
pool_index: Some(pool_index),
|
|
set_index: Some(set_index),
|
|
})
|
|
}
|
|
|
|
fn quota_capability_error(bucket: &str, object: &str) -> StorageError {
|
|
StorageError::NamespaceLockQuorumUnavailable {
|
|
mode: "quota_capability",
|
|
bucket: bucket.to_string(),
|
|
object: object.to_string(),
|
|
required: 1,
|
|
achieved: 0,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn replaced_logical_size(set_disks: &SetDisks, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<u64> {
|
|
if opts.versioned && !opts.version_suspended && opts.version_id.is_none() {
|
|
return Ok(0);
|
|
}
|
|
let version_id = opts
|
|
.version_id
|
|
.clone()
|
|
.or_else(|| opts.version_suspended.then(|| Uuid::nil().to_string()));
|
|
let lookup_opts = ObjectOptions {
|
|
version_id,
|
|
no_lock: true,
|
|
metadata_cache_safe: false,
|
|
versioned: opts.versioned,
|
|
version_suspended: opts.version_suspended,
|
|
..Default::default()
|
|
};
|
|
match set_disks.get_object_info(bucket, object, &lookup_opts).await {
|
|
Ok(info) if info.delete_marker => Ok(0),
|
|
Ok(info) => logical_object_size(&info),
|
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => Ok(0),
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
fn logical_object_size(info: &ObjectInfo) -> Result<u64> {
|
|
crate::data_usage::quota_object_size(info)
|
|
}
|
|
|
|
async fn mark_commit_started(data: &LedgerReservationData) -> Result<()> {
|
|
let data = data.clone();
|
|
tokio::spawn(async move {
|
|
let ledger_lock = data.store.new_ns_lock(RUSTFS_META_BUCKET, &data.ledger_object).await?;
|
|
let ledger_guard = ledger_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
fence_namespace_mutations(&data.store, RUSTFS_META_BUCKET, &data.ledger_object, None).await?;
|
|
let mut ledger = load_ledger_locked(Arc::clone(&data.store), &data.ledger_object).await?;
|
|
ledger.mark_commit_started(data.operation_id, &data.reservation)?;
|
|
save_ledger_locked(Arc::clone(&data.store), &data.ledger_object, &ledger, &ledger_guard).await
|
|
})
|
|
.await
|
|
.map_err(|err| StorageError::other(format!("quota commit marker task failed: {err}")))?
|
|
}
|
|
|
|
async fn settle(data: &LedgerReservationData, committed: bool) -> Result<()> {
|
|
let store = Arc::clone(&data.store);
|
|
let ledger_object = data.ledger_object.clone();
|
|
let operation_id = data.operation_id;
|
|
let reservation = data.reservation.clone();
|
|
tokio::spawn(async move {
|
|
// The commit/abort path releases its own object fence before settlement.
|
|
// Do not revoke all tokens here: a deferred retry can run after the
|
|
// object lock is released and would otherwise revoke a later write's
|
|
// newly acquired fence for the same object.
|
|
let ledger_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &ledger_object).await?;
|
|
let ledger_guard = ledger_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
fence_namespace_mutations(&store, RUSTFS_META_BUCKET, &ledger_object, None).await?;
|
|
let mut ledger = load_ledger_locked(Arc::clone(&store), &ledger_object).await?;
|
|
if committed {
|
|
ledger.commit(operation_id, &reservation)?;
|
|
} else {
|
|
ledger.abort(operation_id, &reservation)?;
|
|
}
|
|
save_ledger_locked(Arc::clone(&store), &ledger_object, &ledger, &ledger_guard).await
|
|
})
|
|
.await
|
|
.map_err(|err| StorageError::other(format!("quota ledger settlement task failed: {err}")))?
|
|
}
|
|
|
|
async fn load_current_ledger_locked(
|
|
store: Arc<ECStore>,
|
|
bucket: &str,
|
|
ledger_object: &str,
|
|
bucket_incarnation: Uuid,
|
|
quota_revision: OffsetDateTime,
|
|
) -> Result<QuotaLedger> {
|
|
match load_ledger_locked(Arc::clone(&store), ledger_object).await {
|
|
Ok(ledger) if ledger.matches(bucket_incarnation, quota_revision) => Ok(ledger),
|
|
Ok(ledger) if ledger.reservations.is_empty() && !ledger.reconcile_required => {
|
|
let usage = exact_bucket_usage(&store, bucket).await?;
|
|
Ok(QuotaLedger::new(bucket_incarnation, quota_revision, usage))
|
|
}
|
|
Ok(_) => Err(StorageError::PartMissingOrCorrupt),
|
|
Err(StorageError::ConfigNotFound) => {
|
|
let usage = exact_bucket_usage(&store, bucket).await?;
|
|
Ok(QuotaLedger::new(bucket_incarnation, quota_revision, usage))
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
async fn reap_stale_reservations(store: Arc<ECStore>, bucket: &str, ledger_object: &str) -> Result<()> {
|
|
let now = now_unix();
|
|
let (candidates, reconcile_required, next_cursor) = {
|
|
let ledger_lock = store.new_ns_lock(RUSTFS_META_BUCKET, ledger_object).await?;
|
|
let _ledger_guard = ledger_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
match load_ledger_locked(Arc::clone(&store), ledger_object).await {
|
|
Ok(ledger) => {
|
|
let (candidates, next_cursor) = ledger.reap_candidates(now);
|
|
(candidates, ledger.reconcile_required, next_cursor)
|
|
}
|
|
Err(StorageError::ConfigNotFound) => (Vec::new(), false, None),
|
|
Err(err) => return Err(err),
|
|
}
|
|
};
|
|
if candidates.is_empty() && !reconcile_required {
|
|
return Ok(());
|
|
}
|
|
|
|
let probe_results = stream::iter(candidates)
|
|
.map(|operation_id| {
|
|
let store = Arc::clone(&store);
|
|
async move {
|
|
let lock_object = operation_lock_object(ledger_object, operation_id);
|
|
let operation_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &lock_object).await?;
|
|
Ok::<_, StorageError>(
|
|
operation_lock
|
|
.get_write_lock_quiet(Duration::from_millis(50))
|
|
.await
|
|
.ok()
|
|
.map(|guard| (operation_id, guard)),
|
|
)
|
|
}
|
|
})
|
|
.buffer_unordered(ORPHAN_PROBE_CONCURRENCY)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
let mut orphan_guards = Vec::new();
|
|
for result in probe_results {
|
|
if let Some(guard) = result? {
|
|
orphan_guards.push(guard);
|
|
if orphan_guards.len() == MAX_ORPHANS_REAPED_PER_WRITE {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let ledger_lock = store.new_ns_lock(RUSTFS_META_BUCKET, ledger_object).await?;
|
|
let ledger_guard = ledger_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
|
fence_namespace_mutations(&store, RUSTFS_META_BUCKET, ledger_object, None).await?;
|
|
let mut ledger = load_ledger_locked(Arc::clone(&store), ledger_object).await?;
|
|
let cursor_changed = next_cursor.is_some() && ledger.reap_cursor != next_cursor;
|
|
if next_cursor.is_some() {
|
|
ledger.reap_cursor = next_cursor;
|
|
}
|
|
let orphan_ids = orphan_guards
|
|
.iter()
|
|
.map(|(operation_id, _)| *operation_id)
|
|
.collect::<Vec<_>>();
|
|
let orphan_commit_targets = orphan_ids
|
|
.iter()
|
|
.filter_map(|operation_id| ledger.reservations.get(operation_id))
|
|
.filter(|reservation| reservation.commit_started)
|
|
.map(|reservation| (reservation.object.clone(), reservation.target()))
|
|
.collect::<Vec<_>>();
|
|
for (object, target) in orphan_commit_targets {
|
|
fence_namespace_mutations(&store, bucket, &object, target).await?;
|
|
}
|
|
let mut removed = remove_orphan_reservations(&mut ledger, &orphan_ids)?;
|
|
if ledger.reservations.is_empty() && ledger.reconcile_required {
|
|
reconcile_exact(&store, bucket, &mut ledger).await?;
|
|
removed = true;
|
|
}
|
|
if !removed && !cursor_changed {
|
|
return Ok(());
|
|
}
|
|
save_ledger_locked(store, ledger_object, &ledger, &ledger_guard).await
|
|
}
|
|
|
|
fn remove_orphan_reservations(ledger: &mut QuotaLedger, operation_ids: &[Uuid]) -> Result<bool> {
|
|
let mut removed = false;
|
|
for operation_id in operation_ids {
|
|
let Some(reservation) = ledger.reservations.get(operation_id).cloned() else {
|
|
continue;
|
|
};
|
|
if reservation.commit_started {
|
|
ledger.reservations.remove(operation_id);
|
|
ledger.reconcile_required = true;
|
|
} else {
|
|
ledger.abort(*operation_id, &reservation)?;
|
|
}
|
|
removed = true;
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
async fn reconcile_exact(store: &Arc<ECStore>, bucket: &str, ledger: &mut QuotaLedger) -> Result<()> {
|
|
if !ledger.reservations.is_empty() {
|
|
return Err(StorageError::PartMissingOrCorrupt);
|
|
}
|
|
ledger.accounted_usage = exact_bucket_usage(store, bucket).await?;
|
|
ledger.reconcile_required = false;
|
|
Ok(())
|
|
}
|
|
|
|
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
|
|
crate::store::list_objects::observe_list_objects_mutation(store, bucket).await;
|
|
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
|
|
}
|
|
|
|
fn ledger_object(bucket: &str) -> String {
|
|
format!("{CONFIG_PREFIX}/quota-ledger/{bucket}.json")
|
|
}
|
|
|
|
fn operation_lock_object(ledger_object: &str, operation_id: Uuid) -> String {
|
|
format!("{ledger_object}.operations/{operation_id}")
|
|
}
|
|
|
|
fn now_unix() -> i64 {
|
|
OffsetDateTime::now_utc().unix_timestamp()
|
|
}
|
|
|
|
async fn fence_namespace_mutations(
|
|
store: &Arc<ECStore>,
|
|
bucket: &str,
|
|
object: &str,
|
|
target: Option<(usize, usize)>,
|
|
) -> Result<()> {
|
|
crate::bucket::utils::check_object_args(bucket, object)?;
|
|
let sets = match target {
|
|
Some((pool_index, set_index)) => {
|
|
let set = store
|
|
.pools
|
|
.get(pool_index)
|
|
.and_then(|pool| pool.disk_set.get(set_index))
|
|
.cloned()
|
|
.ok_or(StorageError::PartMissingOrCorrupt)?;
|
|
vec![set]
|
|
}
|
|
None => store.pools.iter().map(|pool| pool.get_disks_by_key(object)).collect(),
|
|
};
|
|
for set in sets {
|
|
let write_quorum = set.default_write_quorum();
|
|
let disks = set.disks.read().await.iter().flatten().cloned().collect::<Vec<_>>();
|
|
let fence_path = crate::disk::quota_mutation_fence_path(bucket, object);
|
|
let revoke_results = stream::iter(disks)
|
|
.map(|disk| {
|
|
let fence_path = fence_path.clone();
|
|
async move {
|
|
let result = disk
|
|
.release_snapshot_lease(RUSTFS_META_BUCKET, &fence_path, crate::disk::SnapshotLeaseToken::revoke_all())
|
|
.await;
|
|
(disk, result)
|
|
}
|
|
})
|
|
.buffer_unordered(ORPHAN_PROBE_CONCURRENCY)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
let revoked_disks = revoke_results
|
|
.into_iter()
|
|
.filter_map(|(disk, result)| result.is_ok().then_some(disk))
|
|
.collect::<Vec<_>>();
|
|
if revoked_disks.len() < write_quorum {
|
|
return Err(StorageError::ErasureWriteQuorum);
|
|
}
|
|
|
|
let drain_results = stream::iter(revoked_disks)
|
|
.map(|disk| async move {
|
|
match disk.acquire_snapshot_lease(bucket, object).await {
|
|
Ok(token) => disk.release_snapshot_lease(bucket, object, token).await,
|
|
Err(DiskError::FileNotFound | DiskError::VolumeNotFound) => Ok(()),
|
|
Err(err) => Err(err),
|
|
}
|
|
})
|
|
.buffer_unordered(ORPHAN_PROBE_CONCURRENCY)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
if drain_results.iter().filter(|result| result.is_ok()).count() < write_quorum {
|
|
return Err(StorageError::ErasureWriteQuorum);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn fence_namespace_mutations_for_test(
|
|
store: &Arc<ECStore>,
|
|
bucket: &str,
|
|
object: &str,
|
|
target: Option<(usize, usize)>,
|
|
) -> Result<()> {
|
|
fence_namespace_mutations(store, bucket, object, target).await
|
|
}
|
|
|
|
async fn load_ledger_locked(store: Arc<ECStore>, ledger_object: &str) -> Result<QuotaLedger> {
|
|
let data = read_config_no_lock(store, ledger_object).await?;
|
|
let ledger: QuotaLedger = serde_json::from_slice(&data)?;
|
|
if ledger.version != QUOTA_LEDGER_FORMAT_VERSION {
|
|
return Err(StorageError::CorruptedFormat);
|
|
}
|
|
ledger.admitted_usage()?;
|
|
Ok(ledger)
|
|
}
|
|
|
|
async fn save_ledger_locked(
|
|
store: Arc<ECStore>,
|
|
ledger_object: &str,
|
|
ledger: &QuotaLedger,
|
|
ledger_guard: &NamespaceLockGuard,
|
|
) -> Result<()> {
|
|
if ledger_guard.is_lock_lost() {
|
|
return Err(StorageError::NamespaceLockQuorumUnavailable {
|
|
mode: "quota_ledger",
|
|
bucket: RUSTFS_META_BUCKET.to_string(),
|
|
object: ledger_object.to_string(),
|
|
required: 1,
|
|
achieved: 0,
|
|
});
|
|
}
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
if FAIL_NEXT_LEDGER_SAVE.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
|
return Err(StorageError::Unexpected);
|
|
}
|
|
let mut opts = ObjectOptions {
|
|
max_parity: true,
|
|
no_lock: true,
|
|
..Default::default()
|
|
};
|
|
let _ = opts.set_quota_admission(0, u64::MAX);
|
|
opts.add_namespace_lock_guard(ledger_guard);
|
|
save_config_with_opts(store, ledger_object, serde_json::to_vec(ledger)?, &opts).await
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-util"))]
|
|
pub fn fail_next_quota_ledger_save_for_test() {
|
|
FAIL_NEXT_LEDGER_SAVE.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
}
|
|
|
|
fn log_deferred_settlement(data: &LedgerReservationData, state: &'static str, err: &StorageError) {
|
|
warn!(
|
|
event = EVENT_QUOTA_LEDGER_SETTLEMENT,
|
|
component = LOG_COMPONENT_ECSTORE,
|
|
subsystem = LOG_SUBSYSTEM_QUOTA,
|
|
state,
|
|
bucket = %data.bucket,
|
|
operation_id = %data.operation_id,
|
|
error = %err,
|
|
"quota ledger settlement deferred"
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ledger(accounted_usage: u64) -> QuotaLedger {
|
|
QuotaLedger::new(Uuid::new_v4(), OffsetDateTime::now_utc(), accounted_usage)
|
|
}
|
|
|
|
#[test]
|
|
fn ledger_rejects_reserved_growth_overflow() {
|
|
let mut ledger = ledger(u64::MAX);
|
|
let result = ledger.reserve(
|
|
Uuid::new_v4(),
|
|
PersistedReservation {
|
|
object: "object".to_string(),
|
|
old_size: 0,
|
|
new_size: 1,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: false,
|
|
},
|
|
);
|
|
|
|
assert!(matches!(result, Err(StorageError::PartMissingOrCorrupt)));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_reservation_without_topology_uses_conservative_fallback() {
|
|
let reservation: PersistedReservation =
|
|
serde_json::from_str(r#"{"object":"object","old_size":0,"new_size":1,"created_at":0,"commit_started":true}"#)
|
|
.expect("legacy reservation should deserialize");
|
|
|
|
assert_eq!(reservation.target(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn ledger_rejects_persisted_reservations_larger_than_accounted_usage() {
|
|
let mut ledger = ledger(0);
|
|
ledger.reservations.insert(
|
|
Uuid::new_v4(),
|
|
PersistedReservation {
|
|
object: "object".to_string(),
|
|
old_size: 0,
|
|
new_size: 1,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: false,
|
|
},
|
|
);
|
|
|
|
assert!(matches!(ledger.admitted_usage(), Err(StorageError::PartMissingOrCorrupt)));
|
|
}
|
|
|
|
#[test]
|
|
fn ledger_accounts_overwrite_delta_and_reserved_growth() {
|
|
let mut ledger = ledger(10);
|
|
let operation_id = Uuid::new_v4();
|
|
let overwrite = PersistedReservation {
|
|
object: "object".to_string(),
|
|
old_size: 8,
|
|
new_size: 5,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: false,
|
|
};
|
|
ledger
|
|
.reserve(operation_id, overwrite.clone())
|
|
.expect("shrinking overwrite should reserve");
|
|
ledger
|
|
.reserve(
|
|
Uuid::new_v4(),
|
|
PersistedReservation {
|
|
object: "new-object".to_string(),
|
|
old_size: 0,
|
|
new_size: 7,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: false,
|
|
},
|
|
)
|
|
.expect("new object should reserve positive growth");
|
|
|
|
assert_eq!(
|
|
ledger
|
|
.admitted_usage()
|
|
.expect("ledger usage should count positive growth only"),
|
|
17
|
|
);
|
|
ledger
|
|
.commit(operation_id, &overwrite)
|
|
.expect("overwrite should settle exactly");
|
|
assert_eq!(ledger.accounted_usage, 17);
|
|
assert!(ledger.reconcile_required);
|
|
assert_eq!(ledger.admitted_usage().expect("remaining reservation should stay counted"), 17);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_started_reservation_stays_precharged_until_reconciled() {
|
|
let mut ledger = ledger(10);
|
|
let operation_id = Uuid::new_v4();
|
|
ledger
|
|
.reserve(
|
|
operation_id,
|
|
PersistedReservation {
|
|
object: "object".to_string(),
|
|
old_size: 0,
|
|
new_size: 7,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: false,
|
|
},
|
|
)
|
|
.expect("new object should reserve positive growth");
|
|
assert_eq!(ledger.accounted_usage, 17);
|
|
|
|
let expected = ledger
|
|
.reservations
|
|
.get(&operation_id)
|
|
.expect("reservation should exist")
|
|
.clone();
|
|
ledger
|
|
.mark_commit_started(operation_id, &expected)
|
|
.expect("commit marker should persist");
|
|
|
|
assert_eq!(ledger.accounted_usage, 17);
|
|
assert!(!ledger.should_reconcile_after_denial());
|
|
}
|
|
|
|
#[test]
|
|
fn commit_started_orphans_make_progress_across_bounded_batches() {
|
|
let mut ledger = ledger(65);
|
|
let operation_ids = (0..65).map(|_| Uuid::new_v4()).collect::<Vec<_>>();
|
|
for operation_id in &operation_ids {
|
|
ledger.reservations.insert(
|
|
*operation_id,
|
|
PersistedReservation {
|
|
object: format!("object-{operation_id}"),
|
|
old_size: 0,
|
|
new_size: 1,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: true,
|
|
},
|
|
);
|
|
}
|
|
|
|
assert!(remove_orphan_reservations(&mut ledger, &operation_ids[..64]).expect("first orphan batch should apply"));
|
|
assert_eq!(ledger.reservations.len(), 1);
|
|
assert!(ledger.reconcile_required);
|
|
assert!(remove_orphan_reservations(&mut ledger, &operation_ids[64..]).expect("final orphan batch should apply"));
|
|
assert!(ledger.reservations.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn orphan_probe_cursor_rotates_across_the_bounded_window() {
|
|
let mut ledger = ledger(129);
|
|
let operation_ids = (1..=129).map(Uuid::from_u128).collect::<Vec<_>>();
|
|
for operation_id in &operation_ids {
|
|
ledger.reservations.insert(
|
|
*operation_id,
|
|
PersistedReservation {
|
|
object: format!("object-{operation_id}"),
|
|
old_size: 0,
|
|
new_size: 1,
|
|
created_at: 0,
|
|
pool_index: Some(0),
|
|
set_index: Some(0),
|
|
commit_started: false,
|
|
},
|
|
);
|
|
}
|
|
|
|
let (first, cursor) = ledger.reap_candidates(1);
|
|
assert_eq!(first.len(), MAX_ORPHAN_PROBES_PER_WRITE);
|
|
assert_eq!(first.first(), operation_ids.first());
|
|
ledger.reap_cursor = cursor;
|
|
|
|
let (second, _) = ledger.reap_candidates(1);
|
|
assert_eq!(second.first(), operation_ids.last());
|
|
}
|
|
|
|
#[test]
|
|
fn uncertain_fence_release_does_not_schedule_abort_on_drop() {
|
|
assert!(!should_settle_on_drop(ReservationState::FenceReleaseUncertain));
|
|
assert!(!should_settle_on_drop(ReservationState::CommitStarted));
|
|
assert!(should_settle_on_drop(ReservationState::Pending));
|
|
assert!(should_settle_on_drop(ReservationState::Committed));
|
|
}
|
|
}
|