fix(ecstore): reconcile object cleanup receipts (#6077)

* fix(s3): keep multipart completion publication owned

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

* fix(s3): keep put publication owned

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

* chore(app): route multipart context through facade

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

* fix(ecstore): gate object transaction fencing

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

* fix(ecstore): fence object transaction epochs

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

* fix(ecstore): reconcile old data cleanup receipts

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
houseme
2026-08-14 02:08:50 +08:00
committed by GitHub
parent 6178083985
commit 5b54c4303d
11 changed files with 1776 additions and 200 deletions
+208 -104
View File
@@ -2989,6 +2989,11 @@ struct PutObjectChecksums {
crc64nvme: Option<String>,
}
struct PutObjectCommitResult {
obj_info: ObjectInfo,
put_versioned: bool,
}
fn normalize_delete_objects_version_id(
version_id: Option<String>,
) -> std::result::Result<(Option<String>, Option<Uuid>), String> {
@@ -5932,7 +5937,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);
let reader = PutObjReader::new(reader);
let mt2 = metadata.clone();
opts.user_defined.extend(metadata);
@@ -6005,97 +6010,145 @@ impl DefaultObjectUsecase {
} else {
None
};
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
.map_err(ApiError::from)
{
Ok(obj_info) => {
store_put_watchdog.cancel();
debug!(
target: "rustfs::app::object_usecase",
event = EVENT_PUT_OBJECT_STORE_RETURNED,
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
request_id = %request_id,
bucket = %bucket,
key = %key,
put_path = put_path,
object_size = actual_size,
duration_ms = start_time.elapsed().as_millis() as u64,
result = "success",
"PutObject store write returned"
);
obj_info
let put_commit = spawn_traced_join({
let store = Arc::clone(&store);
let bucket = bucket.clone();
let key = key.clone();
let opts = opts.clone();
let cache_adapter = cache_adapter.clone();
let request_id = request_id.clone();
let put_path = put_path.to_string();
async move {
let object_traffic_progress = object_traffic_health
.as_deref()
.and_then(ObjectTrafficHealth::track_write_storage);
let mut reader = reader;
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
.map_err(ApiError::from)
{
Ok(obj_info) => {
store_put_watchdog.cancel();
debug!(
target: "rustfs::app::object_usecase",
event = EVENT_PUT_OBJECT_STORE_RETURNED,
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
request_id = %request_id,
bucket = %bucket,
key = %key,
put_path = %put_path,
object_size = actual_size,
duration_ms = start_time.elapsed().as_millis() as u64,
result = "success",
"PutObject store write returned"
);
obj_info
}
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,
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
request_id = %request_id,
bucket = %bucket,
key = %key,
put_path = %put_path,
object_size = actual_size,
duration_ms = start_time.elapsed().as_millis() as u64,
result = "error",
error = %err,
"PutObject store write returned"
);
return Err(err.into());
}
};
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;
let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).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;
}
}
if dsc.replicate_any() {
schedule_object_replication(obj_info.clone(), store, dsc).await;
}
rustfs_scanner::record_dirty_usage_bucket(&bucket);
rustfs_io_metrics::record_put_object_stage_duration_from("app_post_store_bookkeeping", post_store_stage_start);
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);
Ok::<_, S3Error>(PutObjectCommitResult { obj_info, put_versioned })
}
});
let PutObjectCommitResult { obj_info, put_versioned } = match put_commit.await {
Ok(Ok(result)) => result,
Ok(Err(err)) => {
let result: S3Result<S3Response<PutObjectOutput>> = Err(err);
put_request_guard.finish_err();
let _ = helper.complete(&result);
return result;
}
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,
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_OBJECT,
request_id = %request_id,
bucket = %bucket,
key = %key,
put_path = put_path,
object_size = actual_size,
duration_ms = start_time.elapsed().as_millis() as u64,
result = "error",
error = %err,
"PutObject store write returned"
);
let result: S3Result<S3Response<PutObjectOutput>> = Err(err.into());
let result: S3Result<S3Response<PutObjectOutput>> = Err(S3Error::with_message(
S3ErrorCode::InternalError,
format!("put object commit owner task failed: {err}"),
));
put_request_guard.finish_err();
let _ = helper.complete(&result);
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;
let put_versioned = BucketVersioningSys::prefix_enabled(&bucket, &key).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());
@@ -6110,17 +6163,6 @@ impl DefaultObjectUsecase {
let expiration = resolve_put_object_expiration(&bucket, &obj_info).await;
// Reuse the single replication decision computed before commit (see `dsc`
// above) so the pending metadata persisted with the object and the
// post-commit schedule always derive from the same immutable decision.
// Recomputing here would repeat the versioning/config/target traversal and,
// worse, allow a replication-config hot update between the two phases to
// produce a pending-without-schedule or schedule-without-pending divergence
// (https://github.com/rustfs/backlog/issues/1320).
if dsc.replicate_any() {
schedule_object_replication(obj_info.clone(), store, dsc).await;
}
let mut checksums = PutObjectChecksums {
crc32: input.checksum_crc32,
crc32c: input.checksum_crc32c,
@@ -6159,14 +6201,6 @@ impl DefaultObjectUsecase {
inject_additional_checksum_headers(&mut response.headers, &put_extra_checksum_headers);
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
{
@@ -11507,6 +11541,76 @@ mod tests {
assert!(!recovered.write_stalled);
}
#[tokio::test]
#[serial_test::serial(body_cache_hook)]
async fn cancelled_put_request_completes_post_commit_publication() {
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};
let (store, context) = real_cold_fill_test_context().await;
let bucket = format!("put-owner-tail-{}", Uuid::new_v4());
let object = "object.bin";
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("PUT owner-tail bucket must be created");
let old_body = Bytes::from_static(b"old body that must be invalidated");
let old_info = put_real_cold_fill_object(&store, &bucket, object, &old_body).await;
let adapter = context.object_data_cache();
let old_plan = real_cold_fill_plan(&adapter, &bucket, object, &old_info);
let post_store_entered = Arc::new(tokio::sync::Barrier::new(2));
let post_store_resume = Arc::new(tokio::sync::Barrier::new(2));
install_put_post_store_test_hook(bucket.clone(), Arc::clone(&post_store_entered), Arc::clone(&post_store_resume));
let payload = Bytes::from_static(b"published despite caller cancellation");
let put_input = PutObjectInput::builder()
.bucket(bucket.clone())
.key(object.to_string())
.body(Some(StreamingBlob::from(s3s::Body::from(payload.clone()))))
.content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64")))
.build()
.expect("PUT input must build");
let put_usecase = DefaultObjectUsecase::with_context(Some(Arc::clone(&context)));
let put = tokio::spawn(async move {
put_usecase
.execute_put_object(&FS::new(), build_request(put_input, Method::PUT))
.await
});
tokio::time::timeout(Duration::from_secs(10), post_store_entered.wait())
.await
.expect("PUT must reach the post-store owner-tail hook");
assert_eq!(
adapter.fill_body(&old_plan, old_body.clone()).await,
rustfs_object_data_cache::ObjectDataCacheFillResult::Inserted,
"test must republish the old body while the owner tail is paused"
);
put.abort();
post_store_resume.wait().await;
let _ = put.await.expect_err("outer request task must be cancelled");
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if matches!(
adapter.lookup_body(&old_plan).await,
rustfs_object_data_cache::ObjectDataCacheLookup::Miss
) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("post-commit owner tail must invalidate stale body cache after caller cancellation");
let recovered = store
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("cancelled request's owned commit must still publish the object");
assert_eq!(recovered.size, i64::try_from(payload.len()).expect("test payload length must fit i64"));
}
#[tokio::test]
async fn object_progress_tracks_zero_byte_and_zero_copy_put_lock_waits() {
use crate::app::storage_api::test::contract::bucket::{BucketOperations as _, MakeBucketOptions};