perf(ecstore): backfill rename_data old size and gate the PUT prelookup (#4598)

This commit is contained in:
Zhengchao An
2026-07-09 12:08:01 +08:00
committed by GitHub
parent 579cdf52dc
commit 4f999bb6b8
17 changed files with 1161 additions and 68 deletions
+2
View File
@@ -32,3 +32,5 @@ mod capacity_dirty_scope_test;
mod delete_objects_stat_gating_test;
#[cfg(test)]
mod lifecycle_transition_api_test;
#[cfg(test)]
mod put_prelookup_gating_test;
+103 -28
View File
@@ -19,7 +19,6 @@ use rustfs_io_metrics::buffered_write;
use crate::storage_api::table::get_bucket_metadata;
use super::storage_api::object_usecase::ECStore;
use super::storage_api::object_usecase::access::{
PostObjectRequestMarker, authorize_request, has_bypass_governance_header, req_info_mut,
};
@@ -64,7 +63,7 @@ use super::storage_api::object_usecase::contract::object::{ObjectIO as _, Object
use super::storage_api::object_usecase::contract::range::HTTPRangeSpec;
use super::storage_api::object_usecase::data_usage::{
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
record_bucket_object_write_memory,
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory,
};
use super::storage_api::object_usecase::deadlock_detector;
use super::storage_api::object_usecase::ecfs::FS;
@@ -93,6 +92,7 @@ use super::storage_api::object_usecase::sse::{
};
use super::storage_api::object_usecase::storage_class as storageclass;
use super::storage_api::object_usecase::timeout_wrapper::{GetObjectTimeoutPolicy, RequestTimeoutWrapper};
use super::storage_api::object_usecase::{ECStore, OldCurrentSize};
use super::storage_api::object_usecase::{
RFC1123, check_preconditions, get_validated_store, has_replication_rules, parse_object_lock_legal_hold,
parse_object_lock_retention, parse_part_number_i32_to_usize, remove_object_lock_metadata_for_copy,
@@ -3510,26 +3510,42 @@ impl DefaultObjectUsecase {
)
.await?;
let current_opts: ObjectOptions = internal_object_info_lookup_opts(
get_opts(&bucket, &key, version_id.clone(), None, &req.headers)
.await
.map_err(ApiError::from)?,
);
let previous_current_info = {
crate::hp_guard!("S3::put_object_prelookup");
store.get_object_info(&bucket, &key, &current_opts).await
};
let previous_current_size = match previous_current_info {
Ok(existing_obj_info) => {
validate_existing_object_lock_for_write(&existing_obj_info, &opts)?;
Some(existing_obj_info.size.max(0) as u64)
}
Err(err) => {
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
return Err(ApiError::from(err).into());
// rustfs/backlog#1009: the pre-PUT lookup has exactly two consumers —
// the existing-object WORM validation and usage accounting's
// previous_current_size. When the bucket has no object locking (WORM is
// a provable no-op; the gate fails closed on metadata errors) and the
// PUT targets the latest version (no explicit version_id from internal
// 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() || put_prelookup_worm_gate(&bucket).await;
// 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_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)
.await
.map_err(ApiError::from)?,
);
let previous_current_info = {
crate::hp_guard!("S3::put_object_prelookup");
store.get_object_info(&bucket, &key, &current_opts).await
};
Some(match previous_current_info {
Ok(existing_obj_info) => {
validate_existing_object_lock_for_write(&existing_obj_info, &opts)?;
Some(existing_obj_info.size.max(0) as u64)
}
None
}
Err(err) => {
if !is_err_object_not_found(&err) && !is_err_version_not_found(&err) {
return Err(ApiError::from(err).into());
}
None
}
})
} else {
None
};
let actual_size = size;
@@ -3713,8 +3729,8 @@ impl DefaultObjectUsecase {
}
});
let obj_info = match store
.put_object(&bucket, &key, &mut reader, &opts)
let (obj_info, backfilled_old_current_size) = match store
.put_object_with_old_current_size(&bucket, &key, &mut reader, &opts)
.await
.map_err(ApiError::from)
{
@@ -3764,11 +3780,35 @@ impl DefaultObjectUsecase {
let _ = invalidate_object_data_cache_after_put_success(&cache_adapter, &bucket, &key).await;
let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).await;
// Fast in-memory update for immediate quota and admin usage consistency
if put_versioned {
record_bucket_object_version_write_memory(&bucket, previous_current_size, obj_info.size.max(0) as u64).await;
} else {
record_bucket_object_write_memory(&bucket, previous_current_size, obj_info.size.max(0) as u64).await;
// Fast in-memory update for immediate quota and admin usage consistency.
// The previous current size comes from the prelookup when it ran,
// otherwise from the rename_data backfill (rustfs/backlog#1009); the
// backfill reproduces the lookup's observation bit for bit (latest
// version's ObjectInfo.size — 0 for a delete-marker latest — or
// not-found → None).
match prelookup_previous_current_size.or_else(|| previous_current_size_from_backfill(backfilled_old_current_size)) {
Some(previous_current_size) => {
if put_versioned {
record_bucket_object_version_write_memory(&bucket, previous_current_size, obj_info.size.max(0) as u64).await;
} else {
record_bucket_object_write_memory(&bucket, previous_current_size, obj_info.size.max(0) as u64).await;
}
}
None => {
// Neither source could determine the previous state (peers
// predating the backfill field during a rolling upgrade, or
// sub-quorum metadata divergence). Record the components that
// are correct regardless; the next authoritative scanner
// refresh replaces the in-memory numbers.
debug!(
target: "rustfs::app::object_usecase",
bucket = %bucket,
key = %key,
put_versioned,
"put_object old-size backfill unknown; recording degraded usage delta"
);
record_bucket_object_write_unknown_previous_memory(&bucket, obj_info.size.max(0) as u64, put_versioned).await;
}
}
let raw_version = obj_info.version_id.map(|v| v.to_string());
@@ -6567,6 +6607,28 @@ async fn bucket_object_locking_enabled(bucket: &str) -> bool {
.is_ok_and(|metadata| metadata.object_locking())
}
/// Fail-closed WORM gate for skipping the pre-PUT lookup
/// (rustfs/backlog#1009): a bucket-metadata read failure counts as "locking
/// enabled" so the existing-object lock validation can never silently
/// disappear on a degraded metadata subsystem.
pub(super) async fn put_prelookup_worm_gate(bucket: &str) -> bool {
match get_bucket_metadata(bucket).await {
Ok(metadata) => metadata.object_locking(),
Err(_) => true,
}
}
/// 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
/// caller must fall back to the degraded accounting path.
fn previous_current_size_from_backfill(backfill: Option<OldCurrentSize>) -> Option<Option<u64>> {
backfill.map(|observation| match observation {
OldCurrentSize::Present(size) => Some(size.max(0) as u64),
OldCurrentSize::Absent => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -6662,6 +6724,19 @@ mod tests {
object_info_with_lock_metadata(metadata)
}
/// rustfs/backlog#1009: the backfill→accounting mapping must mirror the
/// prelookup exactly — a live latest version maps to `Some(size)` (clamped
/// at 0 like the prelookup's `.max(0)`), absent/delete-marker maps to
/// `None`, and an unknown backfill maps to outer `None` so the caller
/// takes the degraded path instead of fabricating "new object".
#[test]
fn previous_current_size_from_backfill_mirrors_prelookup_semantics() {
assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(42))), Some(Some(42)));
assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Present(-7))), Some(Some(0)));
assert_eq!(previous_current_size_from_backfill(Some(OldCurrentSize::Absent)), Some(None));
assert_eq!(previous_current_size_from_backfill(None), None);
}
#[test]
fn validate_existing_object_lock_allows_versioned_new_version_with_compliance_retention() {
let opts = ObjectOptions {
+150
View File
@@ -0,0 +1,150 @@
// 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.
//! Regression coverage for rustfs/backlog#1009: the PUT path skips its
//! pre-PUT `get_object_info` only when `put_prelookup_worm_gate` proves the
//! existing-object WORM validation is a no-op. These tests pin the gate's
//! truth table against a real 4-disk `ECStore` with the bucket metadata sys
//! initialized, mirroring the HP-8 delete-gating fixture:
//!
//! - a bucket created with Object Lock keeps the prelookup (gate = true);
//! - a plain bucket takes the skip path (gate = false);
//! - an unknown bucket (metadata lookup error) fails closed (gate = true),
//! so a degraded metadata subsystem can never silently drop the WORM check.
use super::object_usecase::put_prelookup_worm_gate;
use super::storage_api::test::bucket::metadata_sys;
use super::storage_api::test::contract::bucket::{BucketOperations, BucketOptions, MakeBucketOptions};
use super::storage_api::test::{ECStore, Endpoint, EndpointServerPools, Endpoints, PoolEndpoints};
use serial_test::serial;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use tempfile::TempDir;
use tokio::fs;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
static PUT_GATING_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>, TempDir)> = OnceLock::new();
async fn setup_put_gating_env() -> Arc<ECStore> {
if let Some((_paths, store, _)) = PUT_GATING_ENV.get() {
return store.clone();
}
let temp_dir = TempDir::new().expect("create temp dir for put prelookup gating test");
let temp_path = temp_dir.path().to_path_buf();
let disk_paths = vec![
temp_path.join("disk1"),
temp_path.join("disk2"),
temp_path.join("disk3"),
temp_path.join("disk4"),
];
for disk_path in &disk_paths {
fs::create_dir_all(disk_path).await.unwrap();
}
let mut endpoints = Vec::new();
for (i, disk_path) in disk_paths.iter().enumerate() {
let mut endpoint = Endpoint::try_from(disk_path.to_str().unwrap()).unwrap();
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(i);
endpoints.push(endpoint);
}
let pool_endpoints = PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: "put-prelookup-gating-test".to_string(),
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
};
let endpoint_pools = EndpointServerPools(vec![pool_endpoints]);
super::storage_api::test::runtime::init_local_disks(endpoint_pools.clone())
.await
.unwrap();
let server_addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
let ecstore = ECStore::new(server_addr, endpoint_pools, CancellationToken::new())
.await
.unwrap();
let buckets_list = ecstore
.list_bucket(&BucketOptions {
no_metadata: true,
..Default::default()
})
.await
.unwrap();
let buckets = buckets_list.into_iter().map(|v| v.name).collect();
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), buckets).await;
let _ = PUT_GATING_ENV.set((disk_paths, ecstore.clone(), temp_dir));
ecstore
}
#[tokio::test]
#[serial]
async fn worm_gate_keeps_prelookup_for_object_lock_bucket() {
let ecstore = setup_put_gating_env().await;
let bucket = format!("put-gate-lock-{}", Uuid::new_v4());
ecstore
.make_bucket(
&bucket,
&MakeBucketOptions {
lock_enabled: true,
..Default::default()
},
)
.await
.expect("create object-lock bucket");
assert!(
put_prelookup_worm_gate(&bucket).await,
"an object-lock bucket must keep the pre-PUT lookup"
);
}
#[tokio::test]
#[serial]
async fn worm_gate_allows_skip_for_plain_bucket() {
let ecstore = setup_put_gating_env().await;
let bucket = format!("put-gate-plain-{}", Uuid::new_v4());
ecstore
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create plain bucket");
assert!(
!put_prelookup_worm_gate(&bucket).await,
"a bucket without object locking must take the prelookup-skip path"
);
}
#[tokio::test]
#[serial]
async fn worm_gate_fails_closed_when_bucket_metadata_is_unavailable() {
let _ecstore = setup_put_gating_env().await;
let missing_bucket = format!("put-gate-missing-{}", Uuid::new_v4());
assert!(
put_prelookup_worm_gate(&missing_bucket).await,
"a bucket-metadata lookup failure must fail closed and keep the pre-PUT lookup"
);
}
+16 -3
View File
@@ -117,6 +117,19 @@ pub(crate) mod data_usage {
.await;
}
pub(crate) async fn record_bucket_object_write_unknown_previous_memory(
bucket: &str,
new_size: u64,
creates_new_version: bool,
) {
crate::storage::storage_api::ecstore_data_usage::record_bucket_object_write_unknown_previous_memory(
bucket,
new_size,
creates_new_version,
)
.await;
}
pub(crate) async fn remove_bucket_usage_from_backend(
store: Arc<crate::storage::storage_api::ECStore>,
bucket: &str,
@@ -982,9 +995,9 @@ pub(crate) mod object_usecase {
object_utils, options, request_context, s3_api, set_disk, sse, storage_class, timeout_wrapper,
};
pub(crate) use crate::storage::storage_api::{
ECStore, RFC1123, StorageDeletedObject, StorageObjectInfo, StorageObjectLockDeleteOptions, StorageObjectOptions,
StorageObjectToDelete, StoragePutObjReader, check_preconditions, get_validated_store, has_replication_rules,
parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize,
ECStore, OldCurrentSize, RFC1123, StorageDeletedObject, StorageObjectInfo, StorageObjectLockDeleteOptions,
StorageObjectOptions, StorageObjectToDelete, StoragePutObjReader, check_preconditions, get_validated_store,
has_replication_rules, parse_object_lock_legal_hold, parse_object_lock_retention, parse_part_number_i32_to_usize,
remove_object_lock_metadata_for_copy, strip_managed_encryption_metadata, validate_bucket_object_lock_enabled,
validate_object_key, validate_sse_headers_for_read, validate_sse_headers_for_write, validate_ssec_for_read,
wrap_response_with_cors,
+6 -5
View File
@@ -360,9 +360,9 @@ pub(crate) mod ecstore_data_usage {
pub(crate) use rustfs_ecstore::api::data_usage::{
apply_bucket_usage_memory_overlay, init_compression_total_memory_from_backend, load_data_usage_from_backend,
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
record_bucket_object_write_memory, refresh_bucket_usage_from_object_layer,
refresh_versioned_bucket_usage_from_object_layer, remove_bucket_usage_from_backend,
replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory,
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
};
}
@@ -370,8 +370,8 @@ pub(crate) mod ecstore_data_usage {
pub(crate) mod ecstore_disk {
pub(crate) use rustfs_ecstore::api::disk::{
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskStore,
FileInfoVersions, FileReader, FileWriter, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions,
RenameDataResp, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, get_object_disk_read_timeout,
FileInfoVersions, FileReader, FileWriter, OldCurrentSize, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
ReadOptions, RenameDataResp, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, get_object_disk_read_timeout,
validate_batch_read_version_item_count,
};
pub(crate) use rustfs_ecstore::api::disk::{endpoint, error, error_reduce};
@@ -531,6 +531,7 @@ pub(crate) type BatchReadVersionResp = ecstore_disk::BatchReadVersionResp;
pub(crate) type ReadMultipleReq = ecstore_disk::ReadMultipleReq;
pub(crate) type ReadMultipleResp = ecstore_disk::ReadMultipleResp;
pub(crate) type ReadOptions = ecstore_disk::ReadOptions;
pub(crate) type OldCurrentSize = ecstore_disk::OldCurrentSize;
pub(crate) type RenameDataResp = ecstore_disk::RenameDataResp;
pub(crate) type ReplicationStatusType = ecstore_bucket::replication::ReplicationStatusType;
pub(crate) type ReplicationStats = StorageReplicationStatsHandle;