perf(ecstore): reduce inline PUT commit overhead (#6033)

* perf(metrics): attribute PUT stage costs

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

* perf(ecstore): move PUT metadata during shuffle

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

* perf(s3): reuse PUT object lock state

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

* perf(ecstore): trim PUT metadata fanout clones

Build per-disk PUT metadata only for committed writer slots, move the response metadata out of the fanout vector, and preserve fresh FileInfo shuffle semantics.

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

* perf(metrics): make PUT stage attribution opt-in

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

* perf(ecstore): commit inline PUT shards directly

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

* perf(ecstore): streamline rename staging cleanup

Use the directory-specific removal operation for rename_data staging parents. This avoids a guaranteed failed file-removal probe on Unix-like hosts and lets Windows remove the empty directory directly while preserving best-effort non-empty handling.

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

* test(ecstore): cover inline PUT rename failures

Cache the detailed stage metrics gate once per PUT and exercise exact-quorum and quorum-minus-one failures after inline shard encoding.

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-08-13 10:04:20 +08:00
committed by GitHub
parent 846517625b
commit 3a0dbccc2e
9 changed files with 785 additions and 108 deletions
+55 -7
View File
@@ -194,7 +194,7 @@ use std::str::FromStr;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use std::time::{Duration, Instant};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::io::{AsyncRead, ReadBuf};
use tokio::sync::{OwnedSemaphorePermit, RwLock};
@@ -5596,7 +5596,8 @@ impl DefaultObjectUsecase {
// Bucket-quota admission runs exactly once, and only now that the authoritative object length is known. `size` is the same basis the settle phase records via ObjectInfo.size (actual, pre-compression/pre-encryption logical size), NOT the aws-chunked wire Content-Length. When no quota is configured this stays a zero-extra-I/O fast path; once a hard quota is set, checker/config/usage faults fail closed with a retryable error.
self.check_bucket_quota(&bucket, quota_operation, size as u64).await?;
let ingress_stage_start = std::time::Instant::now();
let put_stage_metrics_enabled = rustfs_io_metrics::put_stage_metrics_enabled();
let ingress_stage_start = put_stage_metrics_enabled.then(Instant::now);
let should_compress =
is_disk_compressible(&req.headers, &key) && size > MIN_DISK_COMPRESSIBLE_SIZE as i64 && !ciphertext_passthrough;
let server_side_encryption_requested =
@@ -5660,9 +5661,13 @@ impl DefaultObjectUsecase {
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let bucket_validate_stage_start = put_stage_metrics_enabled.then(Instant::now);
validate_bucket_exists(&store, &bucket).await?;
rustfs_io_metrics::record_put_object_stage_duration_from("app_bucket_validate", bucket_validate_stage_start);
let sse_config_stage_start = put_stage_metrics_enabled.then(Instant::now);
let bucket_sse_config = metadata_sys::get_sse_config(&bucket).await.ok();
rustfs_io_metrics::record_put_object_stage_duration_from("app_sse_config_lookup", sse_config_stage_start);
debug!(
target: "rustfs::app::object_usecase",
component = "app",
@@ -5728,7 +5733,9 @@ impl DefaultObjectUsecase {
let mut metadata = metadata.unwrap_or_default();
let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some();
let object_lock_config_stage_start = put_stage_metrics_enabled.then(Instant::now);
let object_lock_config_state = load_bucket_object_lock_config_state(&bucket).await?;
rustfs_io_metrics::record_put_object_stage_duration_from("app_object_lock_config_lookup", object_lock_config_stage_start);
apply_put_request_metadata(
&mut metadata,
&req.headers,
@@ -5750,6 +5757,7 @@ impl DefaultObjectUsecase {
has_explicit_object_lock_retention,
)?;
let put_opts_stage_start = put_stage_metrics_enabled.then(Instant::now);
let mut opts: ObjectOptions = put_opts_with_replication_authorization(
&bucket,
&key,
@@ -5760,6 +5768,7 @@ impl DefaultObjectUsecase {
)
.await
.map_err(ApiError::from)?;
rustfs_io_metrics::record_put_object_stage_duration_from("app_put_opts_build", put_opts_stage_start);
apply_bucket_generation_guard(&req, &bucket, &mut opts)?;
apply_put_request_object_lock_opts(
&bucket,
@@ -5778,10 +5787,11 @@ impl DefaultObjectUsecase {
// replication), the lookup is skipped and accounting is backfilled from
// the dst xl.meta that rename_data already reads, saving a full-disk
// metadata fanout per PUT.
let prelookup_required = version_id.is_some() || object_lock_checks_required(&bucket).await;
let prelookup_required = version_id.is_some() || object_lock_checks_required_for_state(&object_lock_config_state);
// Outer None = prelookup skipped (accounting comes from the commit
// backfill); Some(inner) = the previous current size as observed by the
// lookup, with the pre-#1009 semantics kept bit-for-bit.
let prelookup_stage_start = (prelookup_required && put_stage_metrics_enabled).then(Instant::now);
let prelookup_previous_current_size: Option<Option<u64>> = if prelookup_required {
let current_opts: ObjectOptions = internal_object_info_lookup_opts(
get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
@@ -5807,6 +5817,7 @@ impl DefaultObjectUsecase {
} else {
None
};
rustfs_io_metrics::record_put_object_stage_duration_from("app_prelookup", prelookup_stage_start);
let actual_size = size;
@@ -5895,15 +5906,13 @@ impl DefaultObjectUsecase {
put_extra_checksum_headers = additional_checksum_echo_pairs(&opts.want_checksum);
}
rustfs_io_metrics::record_put_object_path(put_path);
rustfs_io_metrics::record_put_object_stage_duration(
"ingress_prepare",
ingress_stage_start.elapsed().as_secs_f64() * 1000.0,
);
rustfs_io_metrics::record_put_object_stage_duration_from("ingress_prepare", ingress_stage_start);
let mut helper = OperationHelper::new(&req, event_name, S3Operation::PutObject);
let ssekms_context = extract_ssekms_context_from_headers(&req.headers)?;
// Apply encryption using unified SSE API.
let encryption_stage_start = put_stage_metrics_enabled.then(Instant::now);
let write_principal = SseKmsPrincipal::from_request(&req);
let encryption_request = EncryptionRequest {
bucket: &bucket,
@@ -5947,6 +5956,7 @@ impl DefaultObjectUsecase {
}
reader = write_plan.apply(reader, actual_size).map_err(ApiError::from)?;
rustfs_io_metrics::record_put_object_stage_duration_from("app_encryption_prepare", encryption_stage_start);
let mut reader = PutObjReader::new(reader);
@@ -5963,9 +5973,11 @@ impl DefaultObjectUsecase {
// post-commit schedule (see the reuse site further down), so a
// replication-config hot update can no longer split the two phases
// (https://github.com/rustfs/backlog/issues/1320).
let replication_decision_stage_start = put_stage_metrics_enabled.then(Instant::now);
let dsc =
must_replicate_object(&bucket, &key, &mt2, "".to_string(), opts.delete_marker_replication_status(), opts.clone())
.await;
rustfs_io_metrics::record_put_object_stage_duration_from("app_replication_decision", replication_decision_stage_start);
if dsc.replicate_any() {
insert_str(&mut opts.user_defined, SUFFIX_REPLICATION_TIMESTAMP, jiff::Zoned::now().to_string());
@@ -5977,7 +5989,12 @@ impl DefaultObjectUsecase {
}
let cache_adapter = self.object_data_cache();
let cache_invalidate_before_stage_start = put_stage_metrics_enabled.then(Instant::now);
let _ = invalidate_object_data_cache_before_mutation(&cache_adapter, &bucket, &key).await;
rustfs_io_metrics::record_put_object_stage_duration_from(
"app_cache_invalidate_before",
cache_invalidate_before_stage_start,
);
let store_put_watchdog = tokio_util::sync::CancellationToken::new();
spawn_traced({
@@ -6017,6 +6034,7 @@ impl DefaultObjectUsecase {
let object_traffic_progress = object_traffic_health
.as_deref()
.and_then(ObjectTrafficHealth::track_write_storage);
let store_put_stage_start = put_stage_metrics_enabled.then(Instant::now);
let (obj_info, backfilled_old_current_size) = match store
.put_object_with_old_current_size(&bucket, &key, &mut reader, &opts)
.await
@@ -6042,6 +6060,7 @@ impl DefaultObjectUsecase {
}
Err(err) => {
store_put_watchdog.cancel();
rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start);
warn!(
target: "rustfs::app::object_usecase",
event = EVENT_PUT_OBJECT_STORE_RETURNED,
@@ -6063,10 +6082,12 @@ impl DefaultObjectUsecase {
return result;
}
};
rustfs_io_metrics::record_put_object_stage_duration_from("app_store_put", store_put_stage_start);
drop(object_traffic_progress);
#[cfg(test)]
wait_for_put_post_store_test_hook(&bucket).await;
let post_store_stage_start = put_stage_metrics_enabled.then(Instant::now);
maybe_enqueue_transition_immediate(&obj_info, LcEventSrc::S3PutObject).await;
let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await;
@@ -6165,10 +6186,13 @@ impl DefaultObjectUsecase {
let result = Ok(response);
let _ = helper.complete(&result);
rustfs_scanner::record_dirty_usage_bucket(&bucket);
rustfs_io_metrics::record_put_object_stage_duration_from("app_post_store_bookkeeping", post_store_stage_start);
// Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead)
let capacity_update_stage_start = put_stage_metrics_enabled.then(Instant::now);
let manager = get_capacity_manager();
manager.record_write_operation().await;
rustfs_io_metrics::record_put_object_stage_duration_from("app_capacity_update", capacity_update_stage_start);
// Record PutObject metrics via zero-copy-metrics
{
@@ -9572,6 +9596,13 @@ pub(super) async fn object_lock_checks_required(bucket: &str) -> bool {
.map_or(true, |metadata| metadata.object_locking())
}
fn object_lock_checks_required_for_state(state: &metadata_sys::ObjectLockConfigState) -> bool {
match state {
metadata_sys::ObjectLockConfigState::Configured { .. } | metadata_sys::ObjectLockConfigState::Fabricated => true,
metadata_sys::ObjectLockConfigState::ConfirmedAbsent => false,
}
}
/// rustfs/backlog#1009: map the rename_data old-size backfill onto the
/// `previous_current_size` value the usage-accounting helpers expect. Outer
/// `None` = unknown (no quorum agreement, or a peer predates the field) — the
@@ -10198,6 +10229,23 @@ mod tests {
assert_eq!(err.message(), Some(ERR_OBJECT_LOCK_RETENTION_HEADERS_MUST_BE_PAIRED));
}
#[test]
fn object_lock_checks_required_reuses_authoritative_state() {
assert!(!object_lock_checks_required_for_state(
&metadata_sys::ObjectLockConfigState::ConfirmedAbsent
));
let configured = metadata_sys::ObjectLockConfigState::Configured {
config: ObjectLockConfiguration {
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
rule: None,
},
updated_at: OffsetDateTime::now_utc(),
};
assert!(object_lock_checks_required_for_state(&configured));
assert!(object_lock_checks_required_for_state(&metadata_sys::ObjectLockConfigState::Fabricated));
}
#[test]
fn build_put_like_object_lock_metadata_rejects_retain_until_date_without_mode() {
let retain_until = Timestamp::from(OffsetDateTime::now_utc().add(time::Duration::days(1)));
+53 -4
View File
@@ -24,14 +24,63 @@ pub(crate) async fn init_observability_runtime(store: Arc<ECStore>, ctx: Cancell
init_update_check();
crate::allocator_reclaim::init_allocator_reclaim(ctx.clone());
if startup_runtime_sources::observability_metric_enabled() {
let metrics_enabled = startup_runtime_sources::observability_metric_enabled();
configure_metric_gates(metrics_enabled);
if metrics_enabled {
// Load persisted compression stats into memory early, before any PUTs can occur.
init_compression_total_memory_from_backend(store).await;
startup_runtime_sources::set_put_stage_metrics_enabled(true);
startup_runtime_sources::set_get_stage_metrics_enabled(true);
startup_runtime_sources::set_metrics_enabled(true);
startup_runtime_sources::init_metrics_runtime(ctx.clone());
crate::memory_observability::init_memory_observability(ctx.clone());
init_auto_tuner(ctx).await;
}
}
fn configure_metric_gates(metrics_enabled: bool) {
let put_stage_metrics_enabled = metrics_enabled
&& rustfs_utils::get_env_bool(
rustfs_config::observability::ENV_OBS_PUT_STAGE_METRICS_ENABLED,
rustfs_config::DEFAULT_OBS_PUT_STAGE_METRICS_ENABLED,
);
startup_runtime_sources::set_put_stage_metrics_enabled(put_stage_metrics_enabled);
startup_runtime_sources::set_get_stage_metrics_enabled(metrics_enabled);
startup_runtime_sources::set_metrics_enabled(metrics_enabled);
}
#[cfg(test)]
mod tests {
use super::*;
const PUT_STAGE_ENV: &str = rustfs_config::observability::ENV_OBS_PUT_STAGE_METRICS_ENABLED;
#[test]
#[serial_test::serial]
fn put_stage_metrics_require_explicit_opt_in() {
let previous_metrics = rustfs_io_metrics::metrics_enabled();
let previous_get_stages = rustfs_io_metrics::get_stage_metrics_enabled();
let previous_put_stages = rustfs_io_metrics::put_stage_metrics_enabled();
temp_env::with_var(PUT_STAGE_ENV, None::<&str>, || {
configure_metric_gates(true);
assert!(rustfs_io_metrics::metrics_enabled());
assert!(rustfs_io_metrics::get_stage_metrics_enabled());
assert!(!rustfs_io_metrics::put_stage_metrics_enabled());
});
temp_env::with_var(PUT_STAGE_ENV, Some("true"), || {
configure_metric_gates(true);
assert!(rustfs_io_metrics::metrics_enabled());
assert!(rustfs_io_metrics::get_stage_metrics_enabled());
assert!(rustfs_io_metrics::put_stage_metrics_enabled());
configure_metric_gates(false);
assert!(!rustfs_io_metrics::metrics_enabled());
assert!(!rustfs_io_metrics::get_stage_metrics_enabled());
assert!(!rustfs_io_metrics::put_stage_metrics_enabled());
});
startup_runtime_sources::set_metrics_enabled(previous_metrics);
startup_runtime_sources::set_get_stage_metrics_enabled(previous_get_stages);
startup_runtime_sources::set_put_stage_metrics_enabled(previous_put_stages);
}
}